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:
Camila Belo
2021-08-05 15:55:41 +02:00
parent a7a55632d3
commit bf4ab56750
7 changed files with 416 additions and 280 deletions
@@ -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(() =>
+5
View File
@@ -246,5 +246,10 @@ export interface Config {
* @deprecated
*/
storageUrl?: string;
/**
* (Optional)
*/
legacyUseCaseSensitiveTripletPaths?: boolean;
};
}