From bf4ab567502c0bb29a425e775e6f211a4d1d6d84 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 5 Aug 2021 15:55:41 +0200 Subject: [PATCH] feat(techdocs-common): add legacy casing config for all providers Co-authored-by: Eric Peterson Signed-off-by: Camila Belo --- .../src/stages/publish/awsS3.test.ts | 120 ++++-- .../src/stages/publish/awsS3.ts | 41 +- .../stages/publish/azureBlobStorage.test.ts | 105 +++-- .../src/stages/publish/azureBlobStorage.ts | 43 +- .../src/stages/publish/googleStorage.test.ts | 373 +++++++++--------- .../src/stages/publish/googleStorage.ts | 9 +- plugins/techdocs-backend/config.d.ts | 5 + 7 files changed, 416 insertions(+), 280 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index d25b54c529..715786e3f9 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -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': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.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': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.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`, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 26a896cbac..f0e2d9cb6e 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -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 { try { return await new Promise(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 { 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, diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 60a70a2e0d..78cc7c0c79 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -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': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.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': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.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`, diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 76f91cff66..cb8367ed9d 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -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 { - 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 { - 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`) diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index a1c9d70fd3..ce08e2f2ce 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -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': '', + }, + img: { + 'with spaces.png': 'found it', + 'unsafe.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': '', - }, - img: { - 'with spaces.png': 'found it', - 'unsafe.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(''); 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(''); expect(svgResponse.header).toMatchObject({ diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 3aa7b09b74..9db9b6fb49 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -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(() => diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 7f2fdf8b26..537daa815f 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -246,5 +246,10 @@ export interface Config { * @deprecated */ storageUrl?: string; + + /** + * (Optional) + */ + legacyUseCaseSensitiveTripletPaths?: boolean; }; }