feat(badges): Remove hash implementation and replace it with uuid one.

Signed-off-by: Rbillon59 <r.billon@celonis.com>
This commit is contained in:
Rbillon59
2023-04-21 08:53:28 +02:00
parent 1aabf9e03f
commit 6263ef7ce9
13 changed files with 215 additions and 673 deletions
-6
View File
@@ -20,15 +20,10 @@ import {
} from '@backstage/plugin-badges-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import { DatabaseBadgesStore } from '@backstage/plugin-badges-backend';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const db = await DatabaseBadgesStore.create({
database: env.database,
});
return await createRouter({
config: env.config,
discovery: env.discovery,
@@ -36,6 +31,5 @@ export default async function createPlugin(
tokenManager: env.tokenManager,
logger: env.logger,
identity: env.identity,
db: db,
});
}
+4 -27
View File
@@ -32,10 +32,6 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const db = await DatabaseBadgesStore.create({
database: env.database,
});
return await createRouter({
config: env.config,
discovery: env.discovery,
@@ -43,7 +39,6 @@ export default async function createPlugin(
tokenManager: env.tokenManager,
logger: env.logger,
identity: env.identity,
db: db,
});
}
```
@@ -134,23 +129,6 @@ app:
> Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings.
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: 60 # 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
@@ -169,16 +147,15 @@ The badges backend api exposes two main endpoints for entity badges. The
### If obfuscation is enabled (apps.badges.obfuscate: true)
- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity
hash from name, namespace, kind.
- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity url.
> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. It meant to be called from the frontend plugin.
> 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
- `/badges/entity/:entityUuid/: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
- `/badge/entity/:entityUuid/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.
+12 -26
View File
@@ -7,7 +7,6 @@ import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { GetEntitiesResponse } from '@backstage/catalog-client';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
@@ -85,28 +84,15 @@ export type BadgeSpec = {
// @public
export interface BadgesStore {
// (undocumented)
countAllBadges(): Promise<number>;
addBadge(
name: string,
namespace: string,
kind: string,
): Promise<{
uuid: string;
}>;
// (undocumented)
createAllBadges(
entities: GetEntitiesResponse,
salt: string | undefined,
): Promise<void>;
// (undocumented)
deleteObsoleteHashes(
entities: GetEntitiesResponse,
salt: string | undefined,
): Promise<void>;
// (undocumented)
getAllBadges(): Promise<
{
name: string;
namespace: string;
kind: string;
hash: string;
}[]
>;
// (undocumented)
getBadgeFromHash(hash: string): Promise<
getBadgeFromUuid(uuid: string): Promise<
| {
name: string;
namespace: string;
@@ -115,13 +101,13 @@ export interface BadgesStore {
| undefined
>;
// (undocumented)
getHashFromEntityMetadata(
getUuidFromEntityMetadata(
name: string,
namespace: string,
kind: string,
): Promise<
| {
hash: string;
uuid: string;
}
| undefined
>;
@@ -156,12 +142,12 @@ export interface RouterOptions {
// (undocumented)
badgeFactories?: BadgeFactories;
// (undocumented)
badgeStore?: BadgesStore;
// (undocumented)
catalog?: CatalogApi;
// (undocumented)
config: Config;
// (undocumented)
db: BadgesStore;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
identity: IdentityApi;
@@ -19,15 +19,9 @@ exports.up = async function up(knex) {
table.string('kind').notNullable();
table.string('namespace').notNullable();
table.string('name').notNullable();
table
.string('hash')
.unique()
.comment(
'Hash is calculated from the SHA256 of badge kind, namespace, name, and applications salt',
)
.notNullable();
table.index(['hash'], 'badges_hash_index');
table.primary(['hash']);
table.string('uuid').unique().notNullable();
table.index(['uuid'], 'badges_uuid_index');
table.primary(['kind', 'namespace', 'name']);
});
};
+1
View File
@@ -47,6 +47,7 @@
"knex": "^2.4.2",
"lodash": "^4.17.21",
"supertest": "^6.3.3",
"uuid": "^9.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -19,39 +19,28 @@ import {
resolvePackagePath,
} from '@backstage/backend-common';
import { Knex } from 'knex';
import crypto from 'crypto';
import { GetEntitiesResponse } from '@backstage/catalog-client';
import { v4 as uuidv4 } from 'uuid';
/**
* internal
* @public
*/
export interface BadgesStore {
createAllBadges(
entities: GetEntitiesResponse,
salt: string | undefined,
): Promise<void>;
getBadgeFromHash(
hash: string,
): Promise<{ name: string; namespace: string; kind: string } | undefined>;
getHashFromEntityMetadata(
addBadge(
name: string,
namespace: string,
kind: string,
): Promise<{ hash: string } | undefined>;
): Promise<{ uuid: string }>;
deleteObsoleteHashes(
entities: GetEntitiesResponse,
salt: string | undefined,
): Promise<void>;
getBadgeFromUuid(
uuid: string,
): Promise<{ name: string; namespace: string; kind: string } | undefined>;
countAllBadges(): Promise<number>;
getAllBadges(): Promise<
{ name: string; namespace: string; kind: string; hash: string }[]
>;
getUuidFromEntityMetadata(
name: string,
namespace: string,
kind: string,
): Promise<{ uuid: string } | undefined>;
}
const migrationsDir = resolvePackagePath(
@@ -84,99 +73,47 @@ export class DatabaseBadgesStore implements BadgesStore {
return new DatabaseBadgesStore(client);
}
async createAllBadges(
entities: GetEntitiesResponse,
salt: string,
): Promise<void> {
for (const entity of entities.items) {
const name = entity.metadata.name.toLocaleLowerCase();
const namespace =
entity.metadata.namespace?.toLocaleLowerCase() ?? 'default';
const kind = entity.kind.toLocaleLowerCase();
const entityHash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
await this.db('badges')
.insert({
hash: entityHash,
namespace: namespace,
name: name,
kind: kind,
})
.onConflict()
.ignore();
}
}
async getAllBadges(): Promise<
{ name: string; namespace: string; kind: string; hash: string }[]
> {
const result = await this.db('badges').select('*').orderBy('name', 'asc');
return result;
}
async getBadgeFromHash(
hash: string,
async getBadgeFromUuid(
uuid: string,
): Promise<{ name: string; namespace: string; kind: string } | undefined> {
const result = await this.db('badges')
.select('namespace', 'name', 'kind')
.where({ hash: hash })
.where({ uuid: uuid })
.first();
return result;
}
async getHashFromEntityMetadata(
async getUuidFromEntityMetadata(
name: string,
namespace: string,
kind: string,
): Promise<{ hash: string } | undefined> {
): Promise<{ uuid: string } | undefined> {
const result = await this.db('badges')
.select('hash')
.select('uuid')
.where({ name: name, namespace: namespace, kind: kind })
.first();
return result;
}
async deleteObsoleteHashes(
entities: GetEntitiesResponse,
salt: string,
): Promise<void> {
const entityInCatalog: string[] = [];
const entityInBadgeDatabase: string[] = [];
let entityToDelete: string[] = [];
async addBadge(
name: string,
namespace: string,
kind: string,
): Promise<{ uuid: string }> {
const uuid = uuidv4();
for (const entity of entities.items) {
const name = entity.metadata.name.toLowerCase();
const namespace = entity.metadata.namespace?.toLowerCase() ?? 'default';
const kind = entity.kind.toLowerCase();
const entityHash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
await this.db('badges')
.insert({
uuid: uuid,
name: name,
namespace: namespace,
kind: kind,
})
.onConflict(['name', 'namespace', 'kind'])
.ignore();
entityInCatalog.push(entityHash);
}
for (const entity of await this.getAllBadges()) {
entityInBadgeDatabase.push(entity.hash);
}
entityToDelete = entityInBadgeDatabase.filter(
entity => !entityInCatalog.includes(entity),
);
for (const entity of entityToDelete) {
await this.db('badges').where({ hash: entity }).del();
}
}
async countAllBadges(): Promise<number> {
const result = await this.db('badges').countDistinct('hash as count');
const count: number = +result[0].count;
return count;
return { uuid };
}
}
+34 -104
View File
@@ -17,6 +17,7 @@
import express from 'express';
import Router from 'express-promise-router';
import {
DatabaseManager,
errorHandler,
PluginEndpointDiscovery,
TokenManager,
@@ -27,11 +28,10 @@ import { NotFoundError } from '@backstage/errors';
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
import { BadgeContext, BadgeFactories } from '../types';
import { 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';
import type { BadgesStore } from '../database/badgesStore';
import { BadgesStore, DatabaseBadgesStore } from '../database/badgesStore';
/** @public */
export interface RouterOptions {
@@ -43,7 +43,7 @@ export interface RouterOptions {
tokenManager: TokenManager;
logger: Logger;
identity: IdentityApi;
db: BadgesStore;
badgeStore?: BadgesStore;
}
/** @public */
@@ -57,39 +57,29 @@ export async function createRouter(
new DefaultBadgeBuilder(options.badgeFactories || {});
const router = Router();
const { db, config, logger, tokenManager, discovery, identity } = options;
const { config, logger, tokenManager, discovery, identity } = options;
const baseUrl = await discovery.getExternalBaseUrl('badges');
const salt = config.getOptionalString('custom.badges-backend.salt');
const cacheTimeToLive =
config.getOptionalNumber('badgeDatabaseRefreshCacheTimeToLive') ?? 3600;
let lastDatabaseRefresh = 0;
// Check if the users have enabled the obfuscation of the entity name
if (config.getOptionalBoolean('app.badges.obfuscate')) {
logger.info('Badges obfuscation is enabled');
if (isNil(salt)) {
throw new Error(
'Badges obfuscation is enabled but no salt has been provided',
);
}
const store = options.badgeStore
? options.badgeStore
: await DatabaseBadgesStore.create({
database: await DatabaseManager.fromConfig(config).forPlugin(
'badges',
),
});
// Use the generated hash instead of the triplet namespace/kind/name
router.get('/entity/:entityHash/badge-specs', async (req, res) => {
const { entityHash } = req.params;
// Chech if the database needs to be refreshed
if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) {
lastDatabaseRefresh = await refreshBadgeDatabase();
}
router.get('/entity/:entityUuid/badge-specs', async (req, res) => {
const { entityUuid } = req.params;
// Retrieve the badge info from the database
const badgeInfos = await db.getBadgeFromHash(entityHash);
const badgeInfos = await store.getBadgeFromUuid(entityUuid);
if (isNil(badgeInfos)) {
throw new NotFoundError(
`No badge found for entity hash "${entityHash}"`,
`No badge found for entity uuid "${entityUuid}"`,
);
}
@@ -118,7 +108,7 @@ export async function createRouter(
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeInfo.id),
badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeInfo.id),
config: config,
entity,
};
@@ -133,20 +123,15 @@ export async function createRouter(
res.status(200).json(specs);
});
// Use the generated hash instead of the triplet namespace/kind/name
router.get('/entity/:entityHash/:badgeId', async (req, res) => {
if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) {
lastDatabaseRefresh = await refreshBadgeDatabase();
}
const { entityHash, badgeId } = req.params;
router.get('/entity/:entityUuid/:badgeId', async (req, res) => {
const { entityUuid, badgeId } = req.params;
// Retrieve the badge info from the database
const badgeInfo = await db.getBadgeFromHash(entityHash);
const badgeInfo = await store.getBadgeFromUuid(entityUuid);
if (isNil(badgeInfo)) {
throw new NotFoundError(
`No badge found for entity hash "${entityHash}"`,
`No badge found for entity uuid "${entityUuid}"`,
);
}
@@ -180,7 +165,7 @@ export async function createRouter(
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeId),
badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeId),
config: config,
entity,
},
@@ -201,7 +186,6 @@ export async function createRouter(
res.status(200).send(data);
});
// Generate the hash for queried the namespace/kind/name triplet
router.get(
'/entity/:namespace/:kind/:name/obfuscated',
function authenticate(req, res, next) {
@@ -223,34 +207,21 @@ export async function createRouter(
}
},
async (req, res) => {
if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) {
lastDatabaseRefresh = await refreshBadgeDatabase();
}
const { namespace, kind, name } = req.params;
const storedEntityHash: { hash: string } | undefined =
await db.getHashFromEntityMetadata(name, namespace, kind);
let storedEntityUuid: { uuid: string } | undefined =
await store.getUuidFromEntityMetadata(name, namespace, kind);
if (isNil(storedEntityHash)) {
throw new NotFoundError(
`No hash found for entity "${namespace}/${kind}/${name}"`,
);
if (isNil(storedEntityUuid)) {
storedEntityUuid = await store.addBadge(name, namespace, kind);
if (isNil(storedEntityUuid)) {
throw new NotFoundError(
`No uuid found for entity "${namespace}/${kind}/${name}"`,
);
}
}
// Compare a live calculated hash with the stored one to avoid returning an obsolete entityHash (in case of a salt change)
const liveHash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
if (storedEntityHash.hash !== liveHash) {
throw new NotFoundError(
"Stored Hash and live Hash don't match, did you change the salt? Try to refresh the badge database table and try again",
);
}
return res.status(200).json(storedEntityHash);
return res.status(200).json(storedEntityUuid);
},
);
@@ -348,10 +319,10 @@ export async function createRouter(
// This function return the obfuscated badge url based on the namespace/kind/name triplet
async function getBadgeObfuscatedUrl(
hash: string,
uuid: string,
badgeId: string,
): Promise<string> {
return `${baseUrl}/entity/${hash}/${badgeId}`;
return `${baseUrl}/entity/${uuid}/${badgeId}`;
}
// This function return the badge url based on the namespace/kind/name triplet
@@ -363,45 +334,4 @@ export async function createRouter(
): Promise<string> {
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
}
async function isBadgeDatabaseRefreshNeeded(
lastDatabaseRefreshTimestamp: number,
): Promise<boolean> {
if ((await db.countAllBadges()) === 0) {
logger.info('Badge database is empty, refreshing it');
return true;
}
if (
lastDatabaseRefreshTimestamp === 0 ||
Date.now() - lastDatabaseRefreshTimestamp > cacheTimeToLive * 1000
) {
logger.info(
'Badge database refresh cache to live exceeded, refreshing it',
);
return true;
}
return false;
}
async function refreshBadgeDatabase(): Promise<number> {
const token = await tokenManager.getToken();
const entities = await catalog.getEntities(
{
filter: {
kind: ['Component'],
},
},
token,
);
logger.info(
`Refreshing badge database with ${entities.items.length} entities`,
);
await db.createAllBadges(entities, salt);
logger.info('Badge database refreshed, deleting obsolete hashes');
await db.deleteObsoleteHashes(entities, salt);
return Date.now();
}
}
@@ -70,7 +70,7 @@ export async function startStandaloneServer(
tokenManager,
logger,
identity,
db: await DatabaseBadgesStore.create({
badgeStore: await DatabaseBadgesStore.create({
database: { getClient: async () => database },
}),
});
@@ -18,31 +18,8 @@ import { DatabaseBadgesStore } from '../database/badgesStore';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Knex } from 'knex';
import { Entity } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { GetEntitiesResponse } from '@backstage/catalog-client';
import crypto from 'crypto';
describe('DatabaseBadgesStore', () => {
const 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',
},
},
});
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
@@ -51,28 +28,6 @@ describe('DatabaseBadgesStore', () => {
},
};
const entities: Entity[] = [
entity,
{
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test-2',
},
},
{
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test-3',
},
},
];
const defaultEntityListResponse: GetEntitiesResponse = {
items: entities,
};
const databases = TestDatabases.create();
async function createDatabaseBadgesStore(databaseId: TestDatabaseId) {
@@ -93,53 +48,33 @@ describe('DatabaseBadgesStore', () => {
({ knex, badgeStore } = await createDatabaseBadgesStore(databaseId));
});
it('createAllBadges', async () => {
await badgeStore.createAllBadges(
defaultEntityListResponse,
config.getString('custom.badges-backend.salt'),
it('createABadge', async () => {
const uuid = await badgeStore.addBadge(
entity.metadata.name,
entity.metadata.namespace || 'default',
entity.kind,
);
const storedBadges = await badgeStore.getAllBadges();
const testedEntities: {
name: string;
namespace: string;
kind: string;
hash: string;
}[] = [];
entities.forEach(entityInLoop => {
const kind = entityInLoop.kind.toLowerCase();
const namespace = entityInLoop.metadata.namespace || 'default';
const name = entityInLoop.metadata.name.toLocaleLowerCase();
const salt = config.getString('custom.badges-backend.salt');
const entityHash = crypto
.createHash('sha256')
.update(`${kind}:${namespace}:${name}:${salt}`)
.digest('hex');
testedEntities.push({
hash: entityHash,
name: name,
namespace: namespace,
kind: kind,
});
expect(storedBadges).toEqual(expect.arrayContaining(testedEntities));
expect(storedBadges.length).toEqual(3);
});
const storedBadge = await badgeStore.getBadgeFromUuid(uuid.uuid);
expect(storedBadge?.kind).toEqual(entity.kind);
expect(storedBadge?.name).toEqual(entity.metadata.name);
expect(storedBadge?.namespace).toEqual(
entity.metadata.namespace || 'default',
);
});
it('getBadgeFromHash', async () => {
it('getBadgeFromUuid', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
hash: 'hash1',
uuid: 'uuid1',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedEntity = await badgeStore.getBadgeFromHash('hash1');
const storedEntity = await badgeStore.getBadgeFromUuid('uuid1');
expect(storedEntity).toEqual({
name: 'test',
@@ -148,147 +83,26 @@ describe('DatabaseBadgesStore', () => {
});
});
it('getHashFromEntityMetadata', async () => {
it('getUuidFromEntityMetadata', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
hash: 'hash1',
uuid: 'uuid1',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedHash = await badgeStore.getHashFromEntityMetadata(
const storedUuid = await badgeStore.getUuidFromEntityMetadata(
'test',
'default',
'component',
);
expect(storedHash).toEqual({
hash: 'hash1',
expect(storedUuid).toEqual({
uuid: 'uuid1',
});
});
it('deleteObsoleteHashes', async () => {
const entityHash = crypto
.createHash('sha256')
.update(
`component:default:test:${config.getString(
'custom.badges-backend.salt',
)}`,
)
.digest('hex');
await knex('badges').insert([
{
hash: 'obsoleteHash',
name: 'obsoleteEntity',
namespace: 'default',
kind: 'component',
},
{
hash: entityHash,
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
await badgeStore.deleteObsoleteHashes(
defaultEntityListResponse,
config.getString('custom.badges-backend.salt'),
);
const storedBadges = await badgeStore.getAllBadges();
expect(storedBadges).toEqual(
expect.not.arrayContaining([
{
hash: 'obsoleteHash',
name: 'obsoleteEntity',
namespace: 'default',
kind: 'component',
},
]),
);
expect(storedBadges.length).toEqual(1);
expect(storedBadges).toEqual(
expect.arrayContaining([
{
hash: entityHash,
name: 'test',
namespace: 'default',
kind: 'component',
},
]),
);
});
it('countAllBadges', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
hash: 'hash1',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash2',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash3',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash4',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedBadgesCount = await badgeStore.countAllBadges();
expect(storedBadgesCount).toEqual(4);
});
it('getAllBadges', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
hash: 'hash1',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash2',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash3',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash4',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedBadgesCount = await badgeStore.countAllBadges();
expect(storedBadgesCount).toEqual(4);
});
});
});
@@ -32,7 +32,6 @@ import {
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
import { BadgesStore } from '../database/badgesStore';
import crypto from 'crypto';
describe('createRouter', () => {
let app: express.Express;
@@ -80,18 +79,16 @@ describe('createRouter', () => {
listen: {
port: 7007,
},
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
app: {
badges: {
obfuscate: true,
},
},
custom: {
'badges-backend': {
salt: 'random-string',
cacheTimeToLive: '60',
},
},
});
let discovery: PluginEndpointDiscovery;
@@ -131,40 +128,18 @@ describe('createRouter', () => {
kind: 'component',
};
const badgeEntities = [
{
hash: 'hash1',
name: 'test',
namespace: 'default',
kind: 'component',
},
{
hash: 'hash2',
name: 'test2',
namespace: 'default',
kind: 'component',
},
];
const badgeStore: jest.Mocked<BadgesStore> = {
createAllBadges: jest.fn(),
getBadgeFromHash: jest.fn().mockImplementation(async () => {
addBadge: jest.fn().mockImplementation(async () => {
return { uuid: 'uuid1' };
}),
getBadgeFromUuid: jest.fn().mockImplementation(async () => {
return badgeEntity;
}),
getHashFromEntityMetadata: jest
getUuidFromEntityMetadata: jest
.fn()
.mockImplementation(async () => 'niceHash'),
deleteObsoleteHashes: jest.fn(),
countAllBadges: jest.fn().mockImplementation(async () => 4),
getAllBadges: jest.fn().mockImplementation(async () => badgeEntities),
.mockImplementation(async () => 'uuid1'),
};
const salt = config.getString('custom.badges-backend.salt');
const entityHash = crypto
.createHash('sha256')
.update(`component:default:test:${salt}`)
.digest('hex');
beforeAll(async () => {
discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
@@ -176,7 +151,6 @@ describe('createRouter', () => {
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
db: badgeStore,
});
app = express().use(router);
});
@@ -185,7 +159,7 @@ describe('createRouter', () => {
jest.clearAllMocks();
});
it('works', async () => {
it('works with provided badgeStore', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
@@ -195,7 +169,7 @@ describe('createRouter', () => {
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
db: badgeStore,
badgeStore: badgeStore,
});
expect(router).toBeDefined();
});
@@ -240,113 +214,9 @@ describe('createRouter', () => {
});
});
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/hash1/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\/hash1\/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 });
badgeStore.getHashFromEntityMetadata.mockResolvedValue({
hash: entityHash,
});
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: entityHash,
});
});
it('returns obfuscated 401 if no auth', async () => {
const obfuscatedEntity = await request(app).get(
@@ -354,39 +224,82 @@ describe('createRouter', () => {
);
expect(obfuscatedEntity.status).toEqual(401);
});
});
describe('Errors', () => {
it('returns 404 for unknown entity hash', async () => {
badgeStore.getBadgeFromHash.mockResolvedValue(undefined);
it('returns obfuscated entity and badges', async () => {
const obfuscatedEntity = await request(app)
.get('/entity/default/component/test/obfuscated')
.set('Authorization', 'Bearer fakeToken');
expect(obfuscatedEntity.status).toEqual(200);
expect(obfuscatedEntity.body.uuid).toMatch(
new RegExp(
'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
),
);
const uuid = obfuscatedEntity.body.uuid;
const url = `/entity/${uuid}/test-badge?format=json`;
let response = await request(app).get(url);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
response = await request(app).get(`/entity/${uuid}/test-badge`);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
catalog.getEntities.mockResolvedValueOnce({ items: entities });
catalog.getEntityByRef.mockResolvedValueOnce(entity);
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',
);
response = await request(app).get(`/entity/${uuid}/badge-specs`);
expect(response.status).toEqual(200);
expect(response.body).toEqual([badge]);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(2);
});
describe('Errors', () => {
it('returns 404 for unknown entity uuid', async () => {
badgeStore.getBadgeFromUuid.mockResolvedValue(undefined);
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',
);
});
});
});
});
+10 -10
View File
@@ -75,12 +75,9 @@ describe('createRouter', () => {
};
const badgeStore: jest.Mocked<BadgesStore> = {
createAllBadges: jest.fn(),
getBadgeFromHash: jest.fn(),
getHashFromEntityMetadata: jest.fn(),
deleteObsoleteHashes: jest.fn(),
countAllBadges: jest.fn(),
getAllBadges: jest.fn(),
getBadgeFromUuid: jest.fn(),
getUuidFromEntityMetadata: jest.fn(),
addBadge: jest.fn(),
};
beforeAll(async () => {
@@ -93,6 +90,10 @@ describe('createRouter', () => {
backend: {
baseUrl: 'http://127.0.0.1',
listen: { port: 7007 },
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
});
@@ -123,7 +124,6 @@ describe('createRouter', () => {
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
db: badgeStore,
});
app = express().use(router);
});
@@ -132,7 +132,7 @@ describe('createRouter', () => {
jest.resetAllMocks();
});
it('works', async () => {
it('works with badgeStore', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
@@ -142,7 +142,7 @@ describe('createRouter', () => {
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
db: badgeStore,
badgeStore: badgeStore,
});
expect(router).toBeDefined();
});
@@ -261,7 +261,7 @@ describe('createRouter', () => {
});
});
it('returns 404 for hashed entities', async () => {
it('returns 404 for uuid entities', async () => {
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
+15 -20
View File
@@ -47,17 +47,16 @@ export class BadgesClient implements BadgesApi {
// Check if obfuscation is enabled in the configuration
const obfuscate = this.configApi.getOptionalBoolean('app.badges.obfuscate');
// If obfuscation is enabled, get the hash of the entity and use that to get the badge specs
if (obfuscate) {
const entityHashUrl = await this.getEntityHashUrl(entity);
const entityHash = await this.getEntityHash(entityHashUrl).then(data => {
return data.hash;
const entityUuidUrl = await this.getEntityUuidUrl(entity);
const entityUuid = await this.getEntityUuid(entityUuidUrl).then(data => {
return data.uuid;
});
const entityHashedBadgeSpecsUrl = await this.getEntityHashedBadgeSpecsUrl(
entityHash,
const entityUuidBadgeSpecsUrl = await this.getEntityUuidBadgeSpecsUrl(
entityUuid,
);
const response = await this.fetchApi.fetch(entityHashedBadgeSpecsUrl);
const response = await this.fetchApi.fetch(entityUuidBadgeSpecsUrl);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
@@ -75,8 +74,7 @@ export class BadgesClient implements BadgesApi {
return await response.json();
}
// This function is used to generate the URL to get the badge specs for an entity when obfuscation is enabled. Using the hash
private async getEntityHashUrl(entity: Entity): Promise<string> {
private async getEntityUuidUrl(entity: Entity): Promise<string> {
const routeParams = this.getEntityRouteParams(entity);
const path = generatePath(`:namespace/:kind/:name`, routeParams);
const baseUrl = await this.discoveryApi.getBaseUrl('badges');
@@ -85,25 +83,22 @@ export class BadgesClient implements BadgesApi {
return obfuscatedEntityUrl;
}
// This function is used to get the hash of an entity when obfuscation is enabled. It's calling the badges backend to get the hash
private async getEntityHash(entityHashUrl: string): Promise<any> {
const responseEntityHash = await this.fetchApi.fetch(entityHashUrl);
private async getEntityUuid(entityUuidUrl: string): Promise<any> {
const responseEntityUuid = await this.fetchApi.fetch(entityUuidUrl);
if (!responseEntityHash.ok) {
throw await ResponseError.fromResponse(responseEntityHash);
if (!responseEntityUuid.ok) {
throw await ResponseError.fromResponse(responseEntityUuid);
}
return await responseEntityHash.json();
return await responseEntityUuid.json();
}
// This function is used to generate the URLs to use in the frontend when obfuscation is enabled. It's using the hash of the entity to get the badge specs
private async getEntityHashedBadgeSpecsUrl(entityHash: {
hash: string;
private async getEntityUuidBadgeSpecsUrl(entityUuid: {
uuid: string;
}): Promise<string> {
const baseUrl = await this.discoveryApi.getBaseUrl('badges');
return `${baseUrl}/entity/${entityHash}/badge-specs`;
return `${baseUrl}/entity/${entityUuid}/badge-specs`;
}
// This function is used to generate the URLs to use in the frontend when obfuscation is disabled. It's using the entity name to get the badge specs
private async getEntityBadgeSpecsUrl(entity: Entity): Promise<string> {
const routeParams = this.getEntityRouteParams(entity);
const path = generatePath(`:namespace/:kind/:name`, routeParams);
+1
View File
@@ -4953,6 +4953,7 @@ __metadata:
knex: ^2.4.2
lodash: ^4.17.21
supertest: ^6.3.3
uuid: ^9.0.0
winston: ^3.2.1
yn: ^4.0.0
languageName: unknown