feat(plugins): Update frontend and backend badges plugin to implement an obfuscation feature to limit security risks when opening badges to public

Signed-off-by: Rbillon59 <r.billon@celonis.com>
This commit is contained in:
Rbillon59
2023-03-22 15:49:20 +01:00
parent 8444b0834f
commit a0108c4977
10 changed files with 1045 additions and 131 deletions
+352 -60
View File
@@ -19,13 +19,18 @@ import Router from 'express-promise-router';
import {
errorHandler,
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
import { BadgeContext, BadgeFactories } from '../types';
import { isEmpty, isNil } from 'lodash';
import crypto from 'crypto';
import { Logger } from 'winston';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
/** @public */
export interface RouterOptions {
badgeBuilder?: BadgeBuilder;
@@ -33,6 +38,9 @@ export interface RouterOptions {
catalog?: CatalogApi;
config: Config;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
logger: Logger;
identity: IdentityApi;
}
/** @public */
@@ -46,67 +54,121 @@ export async function createRouter(
new DefaultBadgeBuilder(options.badgeFactories || {});
const router = Router();
router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => {
const { namespace, kind, name } = req.params;
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
{
token: getBearerToken(req.headers.authorization),
},
);
if (!entity) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
const tokenManager = options.tokenManager;
let lookupTable: Map<
string,
{
name: string;
namespace: string;
kind: string;
creationDate: number;
}
> = new Map();
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: await getBadgeUrl(
// Check if the users have enabled the obfuscation of the entity name
const obfuscate: boolean =
options.config.getOptionalBoolean('app.badges.obfuscate') ?? false;
if (obfuscate) {
options.logger.info('Badges obfuscation is enabled');
// Use the generated hash instead of the triplet namespace/kind/name
router.get('/entity/:entityHash/badge-specs', async (req, res) => {
const { entityHash } = req.params;
if (isLookupTableToRefresh(lookupTable)) {
lookupTable = await generateHashLookupTable();
}
// Try to match the queried hash with a key in the lookup table
const entityInfo = await getEntityInfoFromLookupTable(entityHash);
// If a mapping is found, map name, namespace and kind
const name = entityInfo.name;
const namespace = entityInfo.namespace;
const kind = entityInfo.kind;
const token = await tokenManager.getToken();
// Query the catalog with the name, namespace, kind to get the entity informations
const entity = await catalog.getEntityByRef(
{
namespace,
kind,
name,
badgeInfo.id,
options,
),
config: options.config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context });
specs.push(badge);
}
res.status(200).json(specs);
});
router.get(
'/entity/:namespace/:kind/:name/badge/:badgeId',
async (req, res) => {
const { namespace, kind, name, badgeId } = req.params;
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
{
token: getBearerToken(req.headers.authorization),
},
token,
);
if (!entity) {
if (isNil(entity)) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
// Create the badge specs
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: await getBadgeObfuscatedUrl(
namespace,
kind,
name,
badgeInfo.id,
),
config: options.config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({
badgeInfo,
context,
});
specs.push(badge);
}
res.status(200).json(specs);
});
// Use the generated hash instead of the triplet namespace/kind/name
router.get('/entity/:entityHash/:badgeId', async (req, res) => {
const { entityHash, badgeId } = req.params;
if (isLookupTableToRefresh(lookupTable)) {
lookupTable = await generateHashLookupTable();
}
// Try to match the queried hash with a key in the lookup table
const entityInfo = await getEntityInfoFromLookupTable(entityHash);
// If a mapping is found, map name, namespace and kind
const name = entityInfo.name;
const namespace = entityInfo.namespace;
const kind = entityInfo.kind;
const token = await tokenManager.getToken();
const entity = await catalog.getEntityByRef(
{
namespace,
kind,
name,
},
token,
);
if (isNil(entity)) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
res.sendStatus(404),
);
}
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
// Generate the badge URL for the different types of badgeId
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options),
badgeUrl: await getBadgeObfuscatedUrl(namespace, kind, name, badgeId),
config: options.config,
entity,
},
@@ -125,25 +187,255 @@ export async function createRouter(
res.setHeader('Content-Type', format);
res.status(200).send(data);
},
);
});
router.use(errorHandler());
// Generate the hash for queried the namespace/kind/name triplet
router.get(
'/entity/:namespace/:kind/:name/obfuscated',
function authenticate(req, res, next) {
const token =
getBearerTokenFromAuthorizationHeader(req.headers.authorization) ||
(req.cookies?.token as string | undefined);
return router;
}
async function getBadgeUrl(
namespace: string,
kind: string,
name: string,
badgeId: string,
options: RouterOptions,
): Promise<string> {
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
}
function getBearerToken(header?: string): string | undefined {
return header?.match(/Bearer\s+(\S+)/i)?.[1];
if (!token) {
res.status(401).send('Unauthorized');
return;
}
try {
req.user = options.identity.getIdentity({ request: req });
next();
} catch (error) {
options.tokenManager.authenticate(token.toString());
next(error);
}
},
async (req, res) => {
const { namespace, kind, name } = req.params;
const salt = options.config.getString('custom.badges-backend.salt');
const hash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
return res.status(200).json({ hash });
},
);
router.use(errorHandler());
return router;
// If the obfuscation is disabled, use the previously implemented routes
// eslint-disable-next-line no-else-return
} else {
router.get(
'/entity/:namespace/:kind/:name/badge-specs',
async (req, res) => {
const token = await tokenManager.getToken();
const { namespace, kind, name } = req.params;
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
token,
);
if (!entity) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: await getBadgeUrl(namespace, kind, name, badgeInfo.id),
config: options.config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({
badgeInfo,
context,
});
specs.push(badge);
}
res.status(200).json(specs);
},
);
router.get(
'/entity/:namespace/:kind/:name/badge/:badgeId',
async (req, res) => {
const { namespace, kind, name, badgeId } = req.params;
const token = await tokenManager.getToken();
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
token,
);
if (!entity) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId),
config: options.config,
entity,
},
};
let data: string;
if (format === 'application/json') {
data = JSON.stringify(
await badgeBuilder.createBadgeJson(badgeOptions),
null,
2,
);
} else {
data = await badgeBuilder.createBadgeSvg(badgeOptions);
}
res.setHeader('Content-Type', format);
res.status(200).send(data);
},
);
router.use(errorHandler());
return router;
}
// This function generate a table that maps the hash to the namespace/kind/name triplet
async function generateHashLookupTable() {
const logger = options.logger.child({ service: 'badges-backend' });
const generationStartTimestamp = Date.now();
logger.info('Start Generating lookup table');
const token = await options.tokenManager.getToken();
// The salt is used to increase the hash entropy
const salt = options.config.getString('custom.badges-backend.salt');
// Get all the entities in the catalog of kind "Component"
const entitiesList = await catalog.getEntities(
{
filter: {
kind: 'Component',
},
},
token,
);
if (isEmpty(entitiesList)) {
throw new NotFoundError(`No entities found`);
}
// For each entity, generate the hash with the triplet kind:namespace:name then add the salt. Finally add it to the lookup table
entitiesList.items.map(async entity => {
const name = entity.metadata.name.toLocaleLowerCase();
const creationDate: number = Date.now();
const namespace =
entity.metadata.namespace?.toLocaleLowerCase() ?? 'default';
const kind = entity.kind.toLocaleLowerCase();
const hash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
lookupTable.set(hash, { name, namespace, kind, creationDate });
return lookupTable;
});
// Monitor the lookup generation time and log it
logger.info(
`Finished generating lookup table in ${
Date.now() - generationStartTimestamp
} ms`,
);
// Monitor the lookup table size in byte
logger.info(`Lookup table size: ${lookupTable.size} entries`);
return lookupTable;
}
// This function check if the lookup table is empty or if it is expired (based on the cacheTimeToLive config) and need to be refreshed
function isLookupTableToRefresh(
table: Map<
string,
{
name: string;
namespace: string;
kind: string;
creationDate: number;
}
>,
): boolean {
const lookupTableLength = table.size;
if (lookupTableLength === 0) {
return true;
}
const now = Date.now();
const cacheTimeToLive =
options.config.getOptionalNumber(
'custom.badges-backend.cacheTimeToLive',
) ?? 10 * 60 * 1000;
const valuesArray = Array.from(table.values());
const lastEntry = valuesArray[valuesArray.length - 1];
const lastCreationDate = lastEntry.creationDate;
return lastCreationDate + cacheTimeToLive < now;
}
// This function return the namespace/kind/name triplet from the lookup table based on the hash
async function getEntityInfoFromLookupTable(
entityHash: string,
): Promise<{ name: string; namespace: string; kind: string }> {
if (lookupTable.get(entityHash) === undefined) {
throw new NotFoundError(`No entity with hash "${entityHash}"`);
}
const name = lookupTable.get(entityHash)!.name;
const namespace = lookupTable.get(entityHash)!.namespace;
const kind = lookupTable.get(entityHash)!.kind;
return { name, namespace, kind };
}
// This function return the obfuscated badge url based on the namespace/kind/name triplet
async function getBadgeObfuscatedUrl(
namespace: string,
kind: string,
name: string,
badgeId: string,
): Promise<string> {
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
const salt = options.config.getString('custom.badges-backend.salt');
const hash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
return `${baseUrl}/entity/${hash}/${badgeId}`;
}
// This function return the badge url based on the namespace/kind/name triplet
async function getBadgeUrl(
namespace: string,
kind: string,
name: string,
badgeId: string,
): Promise<string> {
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
}
}
@@ -19,9 +19,11 @@ import { Logger } from 'winston';
import {
createServiceBuilder,
loadBackendConfig,
ServerTokenManager,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { createRouter } from './router';
import { IdentityApi } from '@backstage/plugin-auth-node';
export interface ServerOptions {
port: number;
@@ -38,7 +40,27 @@ export async function startStandaloneServer(
logger.debug('Creating application...');
const router = await createRouter({ config, discovery });
const tokenManager = ServerTokenManager.noop();
const identity: IdentityApi = {
async getIdentity({ request }) {
const token = request.headers.authorization?.split(' ')[1];
return {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: token || 'user:default/john_doe',
},
token: token || 'no-token',
};
},
};
const router = await createRouter({
config,
discovery,
tokenManager,
logger,
identity,
});
let service = createServiceBuilder(module)
.setPort(options.port)
@@ -0,0 +1,348 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import request from 'supertest';
import {
getVoidLogger,
PluginEndpointDiscovery,
ServerTokenManager,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';
import { Config, ConfigReader } from '@backstage/config';
import { createRouter } from '../service/router';
import { BadgeBuilder } from '../lib';
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
describe('createRouter', () => {
let app: express.Express;
let badgeBuilder: jest.Mocked<BadgeBuilder>;
const catalog = {
addLocation: jest.fn(),
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getLocationByRef: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
let config: Config;
let discovery: PluginEndpointDiscovery;
const getIdentity = jest.fn();
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
},
};
const entities: Entity[] = [
entity,
{
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test-2',
},
},
];
const badge = {
id: 'test-badge',
badge: {
label: 'test',
message: 'badge',
},
url: '/...',
markdown: '[![...](...)]',
};
beforeAll(async () => {
getIdentity.mockImplementation(
async ({
request: _request,
}: IdentityApiGetIdentityRequest): Promise<
BackstageIdentityResponse | undefined
> => {
return {
identity: {
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
type: 'user',
},
token: 'token',
};
},
);
badgeBuilder = {
getBadges: jest.fn(),
createBadgeJson: jest.fn(),
createBadgeSvg: jest.fn(),
};
config = new ConfigReader({
backend: {
baseUrl: 'http://127.0.0.1',
listen: {
port: 7007,
},
},
app: {
badges: {
obfuscate: true,
},
},
custom: {
'badges-backend': {
salt: 'random-string',
cacheTimeToLive: '60',
},
},
});
discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
it('works', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
expect(router).toBeDefined();
});
describe('GET /entity/:namespace/:kind/:name/badge-specs', () => {
it('does not returns all badge specs for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const response = await request(app).get(
'/entity/default/component/test/badge-specs',
);
expect(response.status).toEqual(404);
});
});
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('does not returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/default/component/test/badge/test-badge',
);
expect(response.status).toEqual(404);
});
it('does not returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url = '/entity/default/component/test/badge/test-badge?format=json';
const response = await request(app).get(url);
expect(response.status).toEqual(404);
});
});
describe('GET /entity/:entityHash/badge-specs', () => {
it('returns all badge specs for entity', async () => {
catalog.getEntities.mockResolvedValueOnce({ items: entities });
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const response = await request(app).get(
'/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/badge-specs',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual([badge]);
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'component',
name: 'test',
},
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/,
),
config,
entity,
},
});
});
});
describe('GET /entity/:entityHash/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'component',
name: 'test',
},
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/,
),
config,
entity,
},
});
});
it('returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url =
'/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge?format=json';
const response = await request(app).get(url);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
});
});
describe('GET /entity/:namespace/:kind/:name/obfuscated', () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
it('returns obfuscated entity', async () => {
const obfuscatedEntity = await request(app)
.get('/entity/default/component/test/obfuscated')
.set('Authorization', 'Bearer fakeToken');
expect(obfuscatedEntity.status).toEqual(200);
// echo -n "component:default:test:random-string" | openssl dgst -sha256
expect(obfuscatedEntity.body).toEqual({
hash: '3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed',
});
});
it('returns obfuscated 401 if no auth', async () => {
const obfuscatedEntity = await request(app).get(
'/entity/default/component/test/obfuscated',
);
expect(obfuscatedEntity.status).toEqual(401);
});
});
describe('Errors', () => {
it('returns 404 for unknown entity hash', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: expect.any(String),
name: 'NotFoundError',
},
request: {
method: 'GET',
url,
},
response: {
statusCode: 404,
},
});
}
await testUrl(
'/entity/3a5f91c1e66519be5394c37a8ba69cfsf3087b7c322c600e7497dc9d517353e5bed/badge-specs',
);
await testUrl(
'/entity/3a5f91c1e66519be5394c37a8ba69c3087b7csfsf322c600e7497dc9d517353e5bed/test-badge',
);
});
});
});
@@ -17,18 +17,25 @@
import express from 'express';
import request from 'supertest';
import {
getVoidLogger,
PluginEndpointDiscovery,
ServerTokenManager,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';
import { Config, ConfigReader } from '@backstage/config';
import { createRouter } from './router';
import { createRouter } from '../service/router';
import { BadgeBuilder } from '../lib';
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
describe('createRouter', () => {
let app: express.Express;
let badgeBuilder: jest.Mocked<BadgeBuilder>;
const catalog = {
addLocation: jest.fn(),
getEntities: jest.fn(),
@@ -42,12 +49,15 @@ describe('createRouter', () => {
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const getIdentity = jest.fn();
let config: Config;
let discovery: PluginEndpointDiscovery;
const entity: Entity = {
apiVersion: 'v1',
kind: 'service',
kind: 'component',
metadata: {
name: 'test',
},
@@ -75,13 +85,34 @@ describe('createRouter', () => {
listen: { port: 7007 },
},
});
discovery = SingleHostDiscovery.fromConfig(config);
getIdentity.mockImplementation(
async ({
request: _request,
}: IdentityApiGetIdentityRequest): Promise<
BackstageIdentityResponse | undefined
> => {
return {
identity: {
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
type: 'user',
},
token: 'token',
};
},
);
discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
app = express().use(router);
});
@@ -91,11 +122,15 @@ describe('createRouter', () => {
});
it('works', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
expect(router).toBeDefined();
});
@@ -108,7 +143,7 @@ describe('createRouter', () => {
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const response = await request(app).get(
'/entity/default/service/test/badge-specs',
'/entity/default/component/test/badge-specs',
);
expect(response.status).toEqual(200);
@@ -118,10 +153,10 @@ describe('createRouter', () => {
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
kind: 'component',
name: 'test',
},
{ token: undefined },
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
@@ -130,46 +165,7 @@ describe('createRouter', () => {
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
),
config,
entity,
},
});
});
});
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/default/service/test/badge/test-badge',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
name: 'test',
},
{ token: undefined },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/,
),
config,
entity,
@@ -177,27 +173,90 @@ describe('createRouter', () => {
});
});
it('returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const url = '/entity/default/service/test/badge/test-badge?format=json';
const response = await request(app).get(url);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
const response = await request(app).get(
'/entity/default/component/test/badge/test-badge',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'component',
name: 'test',
},
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/,
),
config,
entity,
},
});
});
it('returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url =
'/entity/default/component/test/badge/test-badge?format=json';
const response = await request(app).get(url);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
});
});
});
describe('Errors', () => {
it('returns 404 for unknown entities', async () => {
describe('Errors', () => {
it('returns 404 for unknown entities', async () => {
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: 'No component entity in default named "missing"',
name: 'NotFoundError',
},
request: {
method: 'GET',
url,
},
response: {
statusCode: 404,
},
});
}
await testUrl('/entity/default/component/missing/badge-specs');
await testUrl('/entity/default/component/missing/badge/test-badge');
});
});
it('returns 404 for hashed entities', async () => {
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: 'No service entity in default named "missing"',
message: 'No component entity in default named "missing"',
name: 'NotFoundError',
},
request: {
@@ -209,8 +268,8 @@ describe('createRouter', () => {
},
});
}
await testUrl('/entity/default/service/missing/badge-specs');
await testUrl('/entity/default/service/missing/badge/test-badge');
await testUrl('/entity/default/component/missing/badge-specs');
await testUrl('/entity/default/component/missing/badge/test-badge');
});
});
});