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:
@@ -36,6 +36,8 @@ export default async function createPlugin(
|
||||
config: env.config,
|
||||
discovery: env.discovery,
|
||||
badgeFactories: createDefaultBadgeFactories(),
|
||||
tokenManager: env.tokenManager,
|
||||
logger: env.logger,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -112,11 +114,42 @@ export const createMyCustomBadgeFactories = (): BadgeFactories => ({
|
||||
});
|
||||
```
|
||||
|
||||
### Badge obfuscation
|
||||
|
||||
When you enable the obfuscation feature, the badges backend will obfuscate the entity names in the badge link. It's useful when you want your badges to be visible to the public, but you don't want to expose the entity names and also to protect your entity names from being enumerated.
|
||||
|
||||
To enable the obfuscation you need to activate the `obfuscation` feature in the `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
badges:
|
||||
obfuscate: 'true'
|
||||
```
|
||||
|
||||
Also you need to provide the [salt](<https://en.wikipedia.org/wiki/Salt_(cryptography)>) in the `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
custom:
|
||||
badges-backend:
|
||||
salt: <your-salt> # required
|
||||
cacheTimeToLive: 10 # minutes (optional)
|
||||
```
|
||||
|
||||
Any string can be used as a salt, but it's recommended to use a long random string to increase entropy. You can generate a random string using the following command:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
> Note: The salt is used to obfuscate the entity names, so if you change the salt, the entity names will be obfuscated differently and the already established badges (in Github repositories for examples) will need to be updated.
|
||||
|
||||
## API
|
||||
|
||||
The badges backend api exposes two main endpoints for entity badges. The
|
||||
`/badges` prefix is arbitrary, and the default for the example backend.
|
||||
|
||||
### If obfuscation is disabled (default or apps.badges.obfuscate: "false")
|
||||
|
||||
- `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges
|
||||
for a particular entity, in json format. See
|
||||
[BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts)
|
||||
@@ -126,6 +159,22 @@ The badges backend api exposes two main endpoints for entity badges. The
|
||||
an SVG image. If the `accept` request header prefers `application/json` the
|
||||
badge spec as JSON will be returned instead of the image.
|
||||
|
||||
### If obfuscation is enabled (apps.badges.obfuscate: "true")
|
||||
|
||||
- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity
|
||||
hash from name, namespace, kind.
|
||||
|
||||
> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. It meant to be called from the frontend plugin.
|
||||
|
||||
- `/badges/entity/:entityHash/:badgeId` Get the entity badge as an SVG image. If
|
||||
the `accept` request header prefers `application/json` the badge spec as JSON
|
||||
will be returned instead of the image.
|
||||
|
||||
- `/badge/entity/:entityHash/badge-specs` List all defined badges for a
|
||||
particular entity, in json format. See
|
||||
[BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts)
|
||||
from the frontend plugin for a type declaration.
|
||||
|
||||
## Links
|
||||
|
||||
- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges)
|
||||
|
||||
@@ -38,18 +38,32 @@
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@types/express": "^4.17.6",
|
||||
"badge-maker": "^3.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"supertest": "^6.3.3",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/catalog-client": "^1.3.1",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.3"
|
||||
"@backstage/core-app-api": "^1.5.0",
|
||||
"@backstage/dev-utils": "^1.0.12",
|
||||
"@backstage/test-utils": "^1.2.5",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "*",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^0.49.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -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