feat(techdocs-common): add legacy casing config for all providers
Co-authored-by: Eric Peterson <ericpeterson@spotify.com> Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -39,7 +39,13 @@ const getEntityRootDir = (entity: Entity) => {
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const createPublisherFromConfig = (bucketName: string = 'bucketName') => {
|
||||
const createPublisherFromConfig = ({
|
||||
bucketName = 'bucketName',
|
||||
legacyUseCaseSensitiveTripletPaths = false,
|
||||
}: {
|
||||
bucketName?: string;
|
||||
legacyUseCaseSensitiveTripletPaths?: boolean;
|
||||
} = {}) => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
@@ -53,6 +59,7 @@ const createPublisherFromConfig = (bucketName: string = 'bucketName') => {
|
||||
bucketName,
|
||||
},
|
||||
},
|
||||
legacyUseCaseSensitiveTripletPaths,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,42 +103,37 @@ describe('AwsS3Publish', () => {
|
||||
kind: 'single',
|
||||
} as Entity);
|
||||
|
||||
const files = {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
attachments: {
|
||||
'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]),
|
||||
},
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
mockFs({
|
||||
[localRootDir]: {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
attachments: {
|
||||
'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]),
|
||||
},
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
},
|
||||
[storageRootDir]: {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
attachments: {
|
||||
'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]),
|
||||
},
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
},
|
||||
[localRootDir]: files,
|
||||
[storageRootDir]: files,
|
||||
[storageSingleQuoteDir]: {
|
||||
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`,
|
||||
'techdocs_metadata.json': files['techdocs_metadata.json'].replace(
|
||||
/"/g,
|
||||
"'",
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -149,7 +151,9 @@ describe('AwsS3Publish', () => {
|
||||
});
|
||||
|
||||
it('should reject incorrect config', async () => {
|
||||
const publisher = createPublisherFromConfig('errorBucket');
|
||||
const publisher = createPublisherFromConfig({
|
||||
bucketName: 'errorBucket',
|
||||
});
|
||||
expect(await publisher.getReadiness()).toEqual({
|
||||
isAvailable: false,
|
||||
});
|
||||
@@ -167,6 +171,18 @@ describe('AwsS3Publish', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should publish a directory as well when legacy casing is used', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: localRootDir,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail to publish a directory', async () => {
|
||||
const wrongPathToGeneratedDirectory = path.join(
|
||||
rootDir,
|
||||
@@ -201,6 +217,13 @@ describe('AwsS3Publish', () => {
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if docs has been generated even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if docs has not been generated', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
@@ -223,6 +246,15 @@ describe('AwsS3Publish', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
|
||||
techdocsMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata when json encoded with single quotes', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
@@ -281,6 +313,24 @@ describe('AwsS3Publish', () => {
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
app = express().use(publisher.docsRouter());
|
||||
// Ensures leading slash is trimmed and encoded path is decoded.
|
||||
const pngResponse = await request(app).get(
|
||||
`/${entityTripletPath}/img/with%20spaces.png`,
|
||||
);
|
||||
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
|
||||
'found it',
|
||||
);
|
||||
const jsResponse = await request(app).get(
|
||||
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
|
||||
);
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass text/plain content-type for html', async () => {
|
||||
const htmlResponse = await request(app).get(
|
||||
`/${entityTripletPath}/html/unsafe.html`,
|
||||
|
||||
@@ -102,7 +102,17 @@ export class AwsS3Publish implements PublisherBase {
|
||||
...(s3ForcePathStyle && { s3ForcePathStyle }),
|
||||
});
|
||||
|
||||
return new AwsS3Publish(storageClient, bucketName, logger);
|
||||
const legacyPathCasing =
|
||||
config.getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
) || false;
|
||||
|
||||
return new AwsS3Publish(
|
||||
storageClient,
|
||||
bucketName,
|
||||
legacyPathCasing,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
private static buildCredentials(
|
||||
@@ -139,10 +149,12 @@ export class AwsS3Publish implements PublisherBase {
|
||||
constructor(
|
||||
private readonly storageClient: aws.S3,
|
||||
private readonly bucketName: string,
|
||||
private readonly legacyPathCasing: boolean,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.storageClient = storageClient;
|
||||
this.bucketName = bucketName;
|
||||
this.legacyPathCasing = legacyPathCasing;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -204,9 +216,11 @@ export class AwsS3Publish implements PublisherBase {
|
||||
const entityRootDir = `${
|
||||
entity.metadata?.namespace ?? ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind}/${entity.metadata.name}`;
|
||||
const destination = lowerCaseEntityTripletInStoragePath(
|
||||
`${entityRootDir}/${relativeFilePathPosix}`,
|
||||
); // S3 Bucket file relative path
|
||||
|
||||
const relativeFilePathTriplet = `${entityRootDir}/${relativeFilePathPosix}`;
|
||||
const destination = this.legacyPathCasing
|
||||
? relativeFilePathTriplet
|
||||
: lowerCaseEntityTripletInStoragePath(relativeFilePathTriplet); // S3 Bucket file relative path
|
||||
|
||||
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
|
||||
const uploadFile = limiter(() => {
|
||||
@@ -239,9 +253,10 @@ export class AwsS3Publish implements PublisherBase {
|
||||
): Promise<TechDocsMetadata> {
|
||||
try {
|
||||
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
|
||||
const entityRootDir = lowerCaseEntityTriplet(
|
||||
`${entityName.namespace}/${entityName.kind}/${entityName.name}`,
|
||||
);
|
||||
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
|
||||
const entityRootDir = this.legacyPathCasing
|
||||
? entityTriplet
|
||||
: lowerCaseEntityTriplet(entityTriplet);
|
||||
|
||||
const stream = this.storageClient
|
||||
.getObject({
|
||||
@@ -282,7 +297,9 @@ export class AwsS3Publish implements PublisherBase {
|
||||
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
|
||||
|
||||
// filePath example - /default/component/documented-component/index.html
|
||||
const filePath = lowerCaseEntityTripletInStoragePath(decodedUri);
|
||||
const filePath = this.legacyPathCasing
|
||||
? decodedUri
|
||||
: lowerCaseEntityTripletInStoragePath(decodedUri);
|
||||
|
||||
// Files with different extensions (CSS, HTML) need to be served with different headers
|
||||
const fileExtension = path.extname(filePath);
|
||||
@@ -313,9 +330,11 @@ export class AwsS3Publish implements PublisherBase {
|
||||
*/
|
||||
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
|
||||
try {
|
||||
const entityRootDir = lowerCaseEntityTriplet(
|
||||
`${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`,
|
||||
);
|
||||
const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
const entityRootDir = this.legacyPathCasing
|
||||
? entityTriplet
|
||||
: lowerCaseEntityTriplet(entityTriplet);
|
||||
|
||||
await this.storageClient
|
||||
.headObject({
|
||||
Bucket: this.bucketName,
|
||||
|
||||
@@ -44,7 +44,12 @@ jest.spyOn(logger, 'error').mockReturnValue(logger);
|
||||
const createPublisherFromConfig = ({
|
||||
accountName = 'accountName',
|
||||
containerName = 'containerName',
|
||||
}: undefined | { accountName?: string; containerName?: string } = {}) => {
|
||||
legacyUseCaseSensitiveTripletPaths = false,
|
||||
}: {
|
||||
accountName?: string;
|
||||
containerName?: string;
|
||||
legacyUseCaseSensitiveTripletPaths?: boolean;
|
||||
} = {}) => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
@@ -58,6 +63,7 @@ const createPublisherFromConfig = ({
|
||||
containerName,
|
||||
},
|
||||
},
|
||||
legacyUseCaseSensitiveTripletPaths,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,32 +111,37 @@ describe('publishing with valid credentials', () => {
|
||||
(logger.error as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
const files = {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
attachments: {
|
||||
'image.png': Buffer.from([2, 3, 5, 3, 5, 3, 5, 9, 1]),
|
||||
},
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
mockFs({
|
||||
[localRootDir]: {
|
||||
'index.html': 'file-content',
|
||||
'404.html': '',
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
},
|
||||
[storageRootDir]: {
|
||||
'index.html': '',
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
},
|
||||
[localRootDir]: files,
|
||||
[storageRootDir]: files,
|
||||
[storageSingleQuoteDir]: {
|
||||
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`,
|
||||
'techdocs_metadata.json': files['techdocs_metadata.json'].replace(
|
||||
/"/g,
|
||||
"'",
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -175,6 +186,18 @@ describe('publishing with valid credentials', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should publish a directory as well when legacy casing is used', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: localRootDir,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail to publish a directory', async () => {
|
||||
const wrongPathToGeneratedDirectory = path.join(
|
||||
rootDir,
|
||||
@@ -240,6 +263,13 @@ describe('publishing with valid credentials', () => {
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if docs has been generated even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if docs has not been generated', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
@@ -262,6 +292,15 @@ describe('publishing with valid credentials', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
|
||||
techdocsMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata when json encoded with single quotes', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
@@ -324,6 +363,24 @@ describe('publishing with valid credentials', () => {
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
app = express().use(publisher.docsRouter());
|
||||
// Ensures leading slash is trimmed and encoded path is decoded.
|
||||
const pngResponse = await request(app).get(
|
||||
`/${entityTripletPath}/img/with%20spaces.png`,
|
||||
);
|
||||
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
|
||||
'found it',
|
||||
);
|
||||
const jsResponse = await request(app).get(
|
||||
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
|
||||
);
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass text/plain content-type for html', async () => {
|
||||
const htmlResponse = await request(app).get(
|
||||
`/${entityTripletPath}/html/unsafe.html`,
|
||||
|
||||
@@ -89,16 +89,28 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
credential,
|
||||
);
|
||||
|
||||
return new AzureBlobStoragePublish(storageClient, containerName, logger);
|
||||
const legacyPathCasing =
|
||||
config.getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
) || false;
|
||||
|
||||
return new AzureBlobStoragePublish(
|
||||
storageClient,
|
||||
containerName,
|
||||
legacyPathCasing,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly storageClient: BlobServiceClient,
|
||||
private readonly containerName: string,
|
||||
private readonly legacyPathCasing: boolean,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.storageClient = storageClient;
|
||||
this.containerName = containerName;
|
||||
this.legacyPathCasing = legacyPathCasing;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -167,9 +179,12 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
const entityRootDir = `${
|
||||
entity.metadata?.namespace ?? ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind}/${entity.metadata.name}`;
|
||||
const destination = lowerCaseEntityTripletInStoragePath(
|
||||
`${entityRootDir}/${relativeFilePathPosix}`,
|
||||
); // Azure Blob Storage Container file relative path
|
||||
|
||||
const relativeFilePathTriplet = `${entityRootDir}/${relativeFilePathPosix}`;
|
||||
const destination = this.legacyPathCasing
|
||||
? relativeFilePathTriplet
|
||||
: lowerCaseEntityTripletInStoragePath(relativeFilePathTriplet); // Azure Blob Storage Container file relative path
|
||||
|
||||
return limiter(async () => {
|
||||
const response = await this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
@@ -242,9 +257,11 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
async fetchTechDocsMetadata(
|
||||
entityName: EntityName,
|
||||
): Promise<TechDocsMetadata> {
|
||||
const entityRootDir = lowerCaseEntityTriplet(
|
||||
`${entityName.namespace}/${entityName.kind}/${entityName.name}`,
|
||||
);
|
||||
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
|
||||
const entityRootDir = this.legacyPathCasing
|
||||
? entityTriplet
|
||||
: lowerCaseEntityTriplet(entityTriplet);
|
||||
|
||||
try {
|
||||
const techdocsMetadataJson = await this.download(
|
||||
this.containerName,
|
||||
@@ -273,7 +290,9 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
|
||||
|
||||
// filePath example - /default/Component/documented-component/index.html
|
||||
const filePath = lowerCaseEntityTripletInStoragePath(decodedUri);
|
||||
const filePath = this.legacyPathCasing
|
||||
? decodedUri
|
||||
: lowerCaseEntityTripletInStoragePath(decodedUri);
|
||||
|
||||
// Files with different extensions (CSS, HTML) need to be served with different headers
|
||||
const fileExtension = platformPath.extname(filePath);
|
||||
@@ -301,9 +320,11 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
* can be used to verify if there are any pre-generated docs available to serve.
|
||||
*/
|
||||
hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
|
||||
const entityRootDir = lowerCaseEntityTriplet(
|
||||
`${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`,
|
||||
);
|
||||
const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
const entityRootDir = this.legacyPathCasing
|
||||
? entityTriplet
|
||||
: lowerCaseEntityTriplet(entityTriplet);
|
||||
|
||||
return this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(`${entityRootDir}/index.html`)
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
EntityName,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
@@ -27,30 +23,9 @@ import mockFs from 'mock-fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { GoogleGCSPublish } from './googleStorage';
|
||||
import { PublisherBase, TechDocsMetadata } from './types';
|
||||
|
||||
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock Google Cloud Storage client library
|
||||
|
||||
const createMockEntity = (annotations = {}): Entity => {
|
||||
return {
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'test-component-name',
|
||||
namespace: 'test-namespace',
|
||||
annotations: {
|
||||
...annotations,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createMockEntityName = (): EntityName => ({
|
||||
kind: 'TestKind',
|
||||
name: 'test-component-name',
|
||||
namespace: 'test-namespace',
|
||||
});
|
||||
|
||||
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
|
||||
const getEntityRootDir = (entity: Entity) => {
|
||||
@@ -65,10 +40,13 @@ const getEntityRootDir = (entity: Entity) => {
|
||||
const logger = getVoidLogger();
|
||||
jest.spyOn(logger, 'info').mockReturnValue(logger);
|
||||
|
||||
let publisher: PublisherBase;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFs.restore();
|
||||
const createPublisherFromConfig = ({
|
||||
bucketName = 'bucketName',
|
||||
legacyUseCaseSensitiveTripletPaths = false,
|
||||
}: {
|
||||
bucketName?: string;
|
||||
legacyUseCaseSensitiveTripletPaths?: boolean;
|
||||
} = {}) => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
@@ -76,76 +54,127 @@ beforeEach(async () => {
|
||||
type: 'googleGcs',
|
||||
googleGcs: {
|
||||
credentials: '{}',
|
||||
bucketName: 'bucketName',
|
||||
bucketName,
|
||||
},
|
||||
},
|
||||
legacyUseCaseSensitiveTripletPaths,
|
||||
},
|
||||
});
|
||||
|
||||
publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger);
|
||||
});
|
||||
return GoogleGCSPublish.fromConfig(mockConfig, logger);
|
||||
};
|
||||
|
||||
describe('GoogleGCSPublish', () => {
|
||||
const entity = {
|
||||
apiVersion: 'version',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'backstage',
|
||||
namespace: 'default',
|
||||
annotations: {},
|
||||
},
|
||||
};
|
||||
|
||||
const entityName = {
|
||||
kind: 'Component',
|
||||
name: 'backstage',
|
||||
namespace: 'default',
|
||||
};
|
||||
|
||||
const techdocsMetadata = {
|
||||
site_name: 'backstage',
|
||||
site_description: 'site_content',
|
||||
etag: 'etag',
|
||||
};
|
||||
|
||||
const localRootDir = getEntityRootDir(entity);
|
||||
const storageRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
});
|
||||
const storageSingleQuoteDir = getEntityRootDir({
|
||||
metadata: {
|
||||
namespace: 'storage',
|
||||
name: 'quote',
|
||||
},
|
||||
kind: 'single',
|
||||
} as Entity);
|
||||
|
||||
const files = {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
mockFs({
|
||||
[localRootDir]: files,
|
||||
[storageRootDir]: files,
|
||||
[storageSingleQuoteDir]: {
|
||||
'techdocs_metadata.json': files['techdocs_metadata.json'].replace(
|
||||
/"/g,
|
||||
"'",
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('getReadiness', () => {
|
||||
it('should validate correct config', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(await publisher.getReadiness()).toEqual({
|
||||
isAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject incorrect config', async () => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
publisher: {
|
||||
type: 'googleGcs',
|
||||
googleGcs: {
|
||||
credentials: '{}',
|
||||
bucketName: 'errorBucket',
|
||||
},
|
||||
},
|
||||
},
|
||||
const publisher = createPublisherFromConfig({
|
||||
bucketName: 'errorBucket',
|
||||
});
|
||||
|
||||
const errorPublisher = GoogleGCSPublish.fromConfig(mockConfig, logger);
|
||||
|
||||
expect(await errorPublisher.getReadiness()).toEqual({
|
||||
expect(await publisher.getReadiness()).toEqual({
|
||||
isAvailable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish', () => {
|
||||
beforeEach(() => {
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir(entity);
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'index.html': '',
|
||||
'404.html': '',
|
||||
assets: {
|
||||
'main.css': '',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should publish a directory', async () => {
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir(entity);
|
||||
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: entityRootDir,
|
||||
directory: localRootDir,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should publish a directory as well when legacy casing is used', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: localRootDir,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should fail to publish a directory', async () => {
|
||||
@@ -157,14 +186,7 @@ describe('GoogleGCSPublish', () => {
|
||||
'generatedDirectory',
|
||||
);
|
||||
|
||||
const entity = createMockEntity();
|
||||
|
||||
await expect(
|
||||
publisher.publish({
|
||||
entity,
|
||||
directory: wrongPathToGeneratedDirectory,
|
||||
}),
|
||||
).rejects.toThrowError();
|
||||
const publisher = createPublisherFromConfig();
|
||||
|
||||
const fails = publisher.publish({
|
||||
entity,
|
||||
@@ -178,173 +200,136 @@ describe('GoogleGCSPublish', () => {
|
||||
`Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`,
|
||||
),
|
||||
});
|
||||
|
||||
await expect(fails).rejects.toMatchObject({
|
||||
message: expect.stringContaining(wrongPathToGeneratedDirectory),
|
||||
});
|
||||
|
||||
mockFs.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDocsBeenGenerated', () => {
|
||||
it('should return true if docs has been generated', async () => {
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
});
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'index.html': 'file-content',
|
||||
},
|
||||
});
|
||||
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if docs has been generated even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should return false if docs has not been generated', async () => {
|
||||
const entity = createMockEntity();
|
||||
|
||||
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false);
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
await publisher.hasDocsBeenGenerated({
|
||||
kind: 'entity',
|
||||
metadata: {
|
||||
namespace: 'invalid',
|
||||
name: 'triplet',
|
||||
},
|
||||
} as Entity),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTechDocsMetadata', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.restore();
|
||||
it('should return tech docs metadata', async () => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
|
||||
techdocsMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata', async () => {
|
||||
const entityNameMock = createMockEntityName();
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
it('should return tech docs metadata even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'techdocs_metadata.json':
|
||||
'{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}',
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMetadata: TechDocsMetadata = {
|
||||
site_name: 'backstage',
|
||||
site_description: 'site_content',
|
||||
etag: 'etag',
|
||||
};
|
||||
expect(
|
||||
await publisher.fetchTechDocsMetadata(entityNameMock),
|
||||
).toStrictEqual(expectedMetadata);
|
||||
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
|
||||
techdocsMetadata,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return tech docs metadata when json encoded with single quotes', async () => {
|
||||
const entityNameMock = createMockEntityName();
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
});
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'techdocs_metadata.json':
|
||||
"{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}",
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMetadata: TechDocsMetadata = {
|
||||
site_name: 'backstage',
|
||||
site_description: 'site_content',
|
||||
etag: 'etag',
|
||||
};
|
||||
const publisher = createPublisherFromConfig();
|
||||
expect(
|
||||
await publisher.fetchTechDocsMetadata(entityNameMock),
|
||||
).toStrictEqual(expectedMetadata);
|
||||
mockFs.restore();
|
||||
await publisher.fetchTechDocsMetadata({
|
||||
namespace: 'storage',
|
||||
kind: 'single',
|
||||
name: 'quote',
|
||||
}),
|
||||
).toStrictEqual(techdocsMetadata);
|
||||
});
|
||||
|
||||
it('should return an error if the techdocs_metadata.json file is not present', async () => {
|
||||
const entityNameMock = createMockEntityName();
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
});
|
||||
const publisher = createPublisherFromConfig();
|
||||
|
||||
const fails = publisher.fetchTechDocsMetadata(entityNameMock);
|
||||
const invalidEntityName = {
|
||||
namespace: 'invalid',
|
||||
kind: 'triplet',
|
||||
name: 'path',
|
||||
};
|
||||
|
||||
const techDocsMetadaFilePath = path.join(
|
||||
rootDir,
|
||||
...Object.values(invalidEntityName),
|
||||
'techdocs_metadata.json',
|
||||
);
|
||||
|
||||
const fails = publisher.fetchTechDocsMetadata(invalidEntityName);
|
||||
|
||||
await expect(fails).rejects.toMatchObject({
|
||||
message: `The file ${path.join(
|
||||
entityRootDir,
|
||||
'techdocs_metadata.json',
|
||||
)} does not exist !`,
|
||||
message: `The file ${techDocsMetadaFilePath} does not exist !`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('docsRouter', () => {
|
||||
const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
|
||||
let app: express.Express;
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir({
|
||||
...entity,
|
||||
kind: entity.kind.toLowerCase(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const publisher = createPublisherFromConfig();
|
||||
app = express().use(publisher.docsRouter());
|
||||
|
||||
mockFs.restore();
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
html: {
|
||||
'unsafe.html': '<html></html>',
|
||||
},
|
||||
img: {
|
||||
'with spaces.png': 'found it',
|
||||
'unsafe.svg': '<svg></svg>',
|
||||
},
|
||||
'some folder': {
|
||||
'also with spaces.js': 'found it too',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should pass expected object path to bucket', async () => {
|
||||
const {
|
||||
kind,
|
||||
metadata: { namespace, name },
|
||||
} = entity;
|
||||
|
||||
// Ensures leading slash is trimmed and encoded path is decoded.
|
||||
const pngResponse = await request(app).get(
|
||||
`/${namespace}/${kind}/${name}/img/with%20spaces.png`,
|
||||
`/${entityTripletPath}/img/with%20spaces.png`,
|
||||
);
|
||||
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
|
||||
'found it',
|
||||
);
|
||||
const jsResponse = await request(app).get(
|
||||
`/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`,
|
||||
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
|
||||
);
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
|
||||
const publisher = createPublisherFromConfig({
|
||||
legacyUseCaseSensitiveTripletPaths: true,
|
||||
});
|
||||
app = express().use(publisher.docsRouter());
|
||||
// Ensures leading slash is trimmed and encoded path is decoded.
|
||||
const pngResponse = await request(app).get(
|
||||
`/${entityTripletPath}/img/with%20spaces.png`,
|
||||
);
|
||||
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
|
||||
'found it',
|
||||
);
|
||||
const jsResponse = await request(app).get(
|
||||
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
|
||||
);
|
||||
expect(jsResponse.text).toEqual('found it too');
|
||||
});
|
||||
|
||||
it('should pass text/plain content-type for html', async () => {
|
||||
const {
|
||||
kind,
|
||||
metadata: { namespace, name },
|
||||
} = entity;
|
||||
|
||||
const htmlResponse = await request(app).get(
|
||||
`/${namespace}/${kind}/${name}/html/unsafe.html`,
|
||||
`/${entityTripletPath}/html/unsafe.html`,
|
||||
);
|
||||
expect(htmlResponse.text).toEqual('<html></html>');
|
||||
expect(htmlResponse.header).toMatchObject({
|
||||
@@ -352,7 +337,7 @@ describe('GoogleGCSPublish', () => {
|
||||
});
|
||||
|
||||
const svgResponse = await request(app).get(
|
||||
`/${namespace}/${kind}/${name}/img/unsafe.svg`,
|
||||
`/${entityTripletPath}/img/unsafe.svg`,
|
||||
);
|
||||
expect(svgResponse.text).toEqual('<svg></svg>');
|
||||
expect(svgResponse.header).toMatchObject({
|
||||
|
||||
@@ -99,8 +99,8 @@ export class GoogleGCSPublish implements PublisherBase {
|
||||
) {
|
||||
this.storageClient = storageClient;
|
||||
this.bucketName = bucketName;
|
||||
this.logger = logger;
|
||||
this.legacyPathCasing = legacyPathCasing;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,11 +160,10 @@ export class GoogleGCSPublish implements PublisherBase {
|
||||
entity.metadata?.namespace ?? ENTITY_DEFAULT_NAMESPACE
|
||||
}/${entity.kind}/${entity.metadata.name}`;
|
||||
|
||||
const relativeFilePathTriplet = `${entityRootDir}/${relativeFilePathPosix}`;
|
||||
const destination = this.legacyPathCasing
|
||||
? lowerCaseEntityTripletInStoragePath(
|
||||
`${entityRootDir}/${relativeFilePathPosix}`,
|
||||
)
|
||||
: `${entityRootDir}/${relativeFilePathPosix}`; // GCS Bucket file relative path
|
||||
? relativeFilePathTriplet
|
||||
: lowerCaseEntityTripletInStoragePath(relativeFilePathTriplet); // GCS Bucket file relative path
|
||||
|
||||
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
|
||||
const uploadFile = limiter(() =>
|
||||
|
||||
Vendored
+5
@@ -246,5 +246,10 @@ export interface Config {
|
||||
* @deprecated
|
||||
*/
|
||||
storageUrl?: string;
|
||||
|
||||
/**
|
||||
* (Optional)
|
||||
*/
|
||||
legacyUseCaseSensitiveTripletPaths?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user