From d06a045a682e5a0878ce6df561eef1603e6f0464 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 3 May 2022 17:27:01 +0200 Subject: [PATCH] chore: added the user entity for decoration and added some more tests for the router which were untested before Signed-off-by: blam --- .../tasks/NunjucksWorkflowRunner.ts | 6 + .../src/scaffolder/tasks/StorageTaskBroker.ts | 4 + .../src/scaffolder/tasks/types.ts | 1 + .../src/service/router.test.ts | 131 ++++++++++++++++-- .../scaffolder-backend/src/service/router.ts | 14 +- 5 files changed, 144 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 0bd5a3c840..c9d0714392 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -37,6 +37,7 @@ import { TaskSpecV1beta3, TaskStep, } from '@backstage/plugin-scaffolder-common'; +import { UserEntity } from '@backstage/catalog-model'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -52,6 +53,10 @@ type TemplateContext = { [stepName: string]: { output: { [outputName: string]: JsonValue } }; }; secrets?: Record; + user?: { + entity?: UserEntity; + ref?: string; + }; }; const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => { @@ -203,6 +208,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const context: TemplateContext = { parameters: task.spec.parameters, steps: {}, + user: task.spec.user, }; for (const step of task.spec.steps) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index 0aa0b6b830..17f07f702d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -59,6 +59,10 @@ export class TaskManager implements TaskContext { return this.task.secrets; } + get createdBy() { + return this.task.createdBy; + } + async getWorkspaceName() { return this.task.taskId; } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index bbd0da2565..a3a9ef84d1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -109,6 +109,7 @@ export type TaskBrokerDispatchOptions = { export interface TaskContext { spec: TaskSpec; secrets?: TaskSecrets; + createdBy?: string; done: boolean; emitLog(message: string, logMetadata?: JsonObject): Promise; complete(result: TaskCompletionState, metadata?: JsonObject): Promise; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 6caed7b0cb..3f3cf1f4d7 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -48,12 +48,11 @@ import request from 'supertest'; */ import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; -import { stringifyEntityRef } from '@backstage/catalog-model'; - -const createCatalogClient = (template: any) => - ({ - getEntityByRef: async () => template, - } as unknown as CatalogApi); +import { + parseEntityRef, + stringifyEntityRef, + UserEntity, +} from '@backstage/catalog-model'; function createDatabase(): PluginDatabaseManager { return DatabaseManager.fromConfig( @@ -76,7 +75,9 @@ const mockUrlReader = UrlReaders.default({ describe('createRouter', () => { let app: express.Express; let taskBroker: TaskBroker; - const template: TemplateEntityV1beta3 = { + const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi; + + const mockTemplate: TemplateEntityV1beta3 = { apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', metadata: { @@ -105,6 +106,22 @@ describe('createRouter', () => { }, }; + const mockUser: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + annotations: { + 'google.com/email': 'bobby@tables.com', + }, + }, + spec: { + profile: { + displayName: 'Robert Tables of the North', + }, + }, + }; + beforeEach(async () => { const logger = getVoidLogger(); const databaseTaskStore = await DatabaseTaskStore.create({ @@ -120,11 +137,26 @@ describe('createRouter', () => { logger: getVoidLogger(), config: new ConfigReader({}), database: createDatabase(), - catalogClient: createCatalogClient(template), + catalogClient, reader: mockUrlReader, taskBroker, }); app = express().use(router); + + jest + .spyOn(catalogClient, 'getEntityByRef') + .mockImplementation(async ref => { + const { kind } = parseEntityRef(ref); + + if (kind === 'template') { + return mockTemplate; + } + + if (kind === 'user') { + return mockUser; + } + throw new Error(`no mock found for kind: ${kind}`); + }); }); afterEach(() => { @@ -158,9 +190,8 @@ describe('createRouter', () => { }); it('return the template id', async () => { - ( - taskBroker.dispatch as jest.Mocked['dispatch'] - ).mockResolvedValue({ + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + broker.mockResolvedValue({ taskId: 'a-random-id', }); @@ -179,6 +210,84 @@ describe('createRouter', () => { expect(response.body.id).toBe('a-random-id'); expect(response.status).toEqual(201); }); + + it('should call the broker with a correct spec', async () => { + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + const mockToken = + 'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob'; + + await request(app) + .post('/v2/tasks') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: 'user:default/guest', + secrets: { + backstageToken: mockToken, + }, + + spec: { + apiVersion: mockTemplate.apiVersion, + steps: mockTemplate.spec.steps.map((step, index) => ({ + ...step, + id: step.id ?? `step-${index + 1}`, + name: step.name ?? step.action, + })), + output: mockTemplate.spec.output ?? {}, + parameters: { + required: 'required-value', + }, + user: { + entity: mockUser, + ref: 'user:default/guest', + }, + templateInfo: { + entityRef: stringifyEntityRef({ + kind: 'Template', + namespace: 'Default', + name: mockTemplate.metadata?.name, + }), + baseUrl: 'https://dev.azure.com', + }, + }, + }), + ); + }); + + it('should not decorate a user when no backstage auth is passed', async () => { + const broker = taskBroker.dispatch as jest.Mocked['dispatch']; + + await request(app) + .post('/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + required: 'required-value', + }, + }); + + expect(broker).toHaveBeenCalledWith( + expect.objectContaining({ + createdBy: undefined, + spec: expect.objectContaining({ + user: { entity: undefined, ref: undefined }, + }), + }), + ); + }); }); describe('GET /v2/tasks/:taskId', () => { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 94c2694f9a..5a6690edab 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -16,7 +16,11 @@ import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; -import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { + parseEntityRef, + stringifyEntityRef, + UserEntity, +} from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, NotFoundError } from '@backstage/errors'; @@ -173,6 +177,10 @@ export async function createRouter( req.headers.authorization, ); + const userEntity = userEntityRef + ? await catalogClient.getEntityByRef(userEntityRef, { token }) + : undefined; + const values = req.body.values; const template = await findTemplate({ @@ -208,6 +216,10 @@ export async function createRouter( })), output: template.spec.output ?? {}, parameters: values, + user: { + entity: userEntity as UserEntity, + ref: userEntityRef, + }, templateInfo: { entityRef: stringifyEntityRef({ kind,