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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-59
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user