From e92e481fed6c1a0effcb57ca34917a782c65340e Mon Sep 17 00:00:00 2001 From: solimant Date: Tue, 13 May 2025 01:29:23 +0000 Subject: [PATCH 1/4] Add tests for Scaffolder Signed-off-by: solimant --- .changeset/tender-trees-open.md | 5 + .../src/ScaffolderPlugin.test.ts | 1332 +++++++++++++++++ .../actions/builtin/filesystem/read.ts | 2 + 3 files changed, 1339 insertions(+) create mode 100644 .changeset/tender-trees-open.md create mode 100644 plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts diff --git a/.changeset/tender-trees-open.md b/.changeset/tender-trees-open.md new file mode 100644 index 0000000000..c54fd6574f --- /dev/null +++ b/.changeset/tender-trees-open.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add tests for Scaffolder diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts new file mode 100644 index 0000000000..27c00cd1e7 --- /dev/null +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts @@ -0,0 +1,1332 @@ +/* + * Copyright 2025 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 { IncomingMessage } from 'http'; +import request from 'supertest'; +import waitForExpect from 'wait-for-expect'; + +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import { scaffolderAutocompleteExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; + +import { scaffolderPlugin } from './ScaffolderPlugin'; + +jest.mock('@backstage/catalog-client'); + +describe('scaffolderPlugin', () => { + const getEntityByRefSpy = jest.spyOn( + CatalogClient.prototype, + 'getEntityByRef', + ); + + const getMockTemplate = (): TemplateEntityV1beta3 => ({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, + }, + spec: { + owner: 'web@example.com', + type: 'website', + steps: [ + { + id: 'step-one', + name: 'First log', + action: 'debug:log', + input: { + message: 'hello', + }, + }, + { + id: 'step-two', + name: 'Second log', + action: 'debug:log', + input: { + message: 'world', + }, + 'backstage:permissions': { + tags: ['steps-tag'], + }, + }, + ], + parameters: [ + { + type: 'object', + required: ['requiredParameter1'], + properties: { + requiredParameter1: { + type: 'string', + description: 'Required parameter 1', + }, + }, + }, + { + type: 'object', + required: ['requiredParameter2'], + 'backstage:permissions': { + tags: ['parameters-tag'], + }, + properties: { + requiredParameter2: { + type: 'string', + description: 'Required parameter 2', + }, + }, + }, + ], + }, + }); + + beforeEach(() => { + getEntityByRefSpy.mockImplementation(async () => getMockTemplate()); + }); + + afterEach(() => { + getEntityByRefSpy.mockReset(); + }); + + it('supports fetching template parameters schema', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + const { body, status } = await request(server).get( + '/api/scaffolder/v2/templates/default/template/create-react-app-template/parameter-schema', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + description: 'Create a new CRA website project', + steps: [ + { + schema: { + properties: { + requiredParameter1: { + description: 'Required parameter 1', + type: 'string', + }, + }, + required: ['requiredParameter1'], + type: 'object', + }, + title: 'Please enter the following information', + }, + { + schema: { + 'backstage:permissions': { tags: ['parameters-tag'] }, + properties: { + requiredParameter2: { + description: 'Required parameter 2', + type: 'string', + }, + }, + required: ['requiredParameter2'], + type: 'object', + }, + title: 'Please enter the following information', + }, + ], + title: 'Create React App Template', + }); + }); + + it('supports listing actions', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + const { body, status } = await request(server).get( + '/api/scaffolder/v2/actions', + ); + + expect(status).toBe(200); + expect(body).toBeInstanceOf(Array); + + const actionSchema = { + id: expect.any(String), + description: expect.any(String), + examples: expect.any(Array), + schema: expect.any(Object), + }; + + // General cursory check + body.forEach((action: any) => expect(action).toMatchObject(actionSchema)); + + expect(body).toContainEqual({ + id: 'fetch:plain', + description: + 'Downloads content and places it in the workspace, or optionally in a subdirectory specified by the `targetPath` input option.', + examples: [ + { + description: 'Downloads content and places it in the workspace.', + example: + 'steps:\n - action: fetch:plain\n id: fetch-plain\n name: Fetch plain\n input:\n url: https://github.com/backstage/community/tree/main/backstage-community-sessions/assets\n', + }, + { + description: + 'Optionally, if you would prefer the data to be downloaded to a subdirectory in the workspace you may specify the ‘targetPath’ input option.', + example: + 'steps:\n - action: fetch:plain\n id: fetch-plain\n name: Fetch plain\n input:\n url: https://github.com/backstage/community/tree/main/backstage-community-sessions/assets\n targetPath: fetched-data\n', + }, + ], + schema: { + input: { + type: 'object', + required: ['url'], + properties: { + url: { + title: 'Fetch URL', + description: + 'Relative path or absolute URL pointing to the directory tree to fetch', + type: 'string', + }, + targetPath: { + title: 'Target Path', + description: + 'Target path within the working directory to download the contents to.', + type: 'string', + }, + token: { + title: 'Token', + description: + 'An optional token to use for authentication when reading the resources.', + type: 'string', + }, + }, + }, + }, + }); + }); + + it('supports listing tasks', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + const { body, status } = await request(server).get( + '/api/scaffolder/v2/tasks', + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ tasks: [], totalTasks: 0 }); + }); + + it('rejects creating tasks if template schema definition mismatches', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); + + expect(response.status).toBe(400); + }); + + it('supports creating tasks', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + tasks: expect.any(Array), + totalTasks: 1, + }); + + const { tasks } = response.body; + expect(tasks.length).toBe(1); + expect(tasks).toContainEqual({ + createdAt: expect.any(String), + createdBy: 'user:default/mock', + id: taskId, + lastHeartbeatAt: expect.any(String), + spec: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + output: {}, + parameters: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + steps: [ + { + action: 'debug:log', + id: 'step-one', + input: { message: 'hello' }, + name: 'First log', + }, + { + action: 'debug:log', + 'backstage:permissions': { tags: ['steps-tag'] }, + id: 'step-two', + input: { message: 'world' }, + name: 'Second log', + }, + ], + templateInfo: { + baseUrl: 'https://dev.azure.com', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + }, + entityRef: 'template:default/create-react-app-template', + }, + user: { + entity: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + parameters: [ + { + properties: { + requiredParameter1: { + description: 'Required parameter 1', + type: 'string', + }, + }, + required: ['requiredParameter1'], + type: 'object', + }, + { + 'backstage:permissions': { tags: ['parameters-tag'] }, + properties: { + requiredParameter2: { + description: 'Required parameter 2', + type: 'string', + }, + }, + required: ['requiredParameter2'], + type: 'object', + }, + ], + steps: [ + { + action: 'debug:log', + id: 'step-one', + input: { message: 'hello' }, + name: 'First log', + }, + { + action: 'debug:log', + 'backstage:permissions': { tags: ['steps-tag'] }, + id: 'step-two', + input: { message: 'world' }, + name: 'Second log', + }, + ], + type: 'website', + }, + }, + ref: 'user:default/mock', + }, + }, + status: 'completed', + }); + }); + + it('emits auditlog containing user identifier when backstage auth is passed', async () => { + const mockToken = mockCredentials.user.token(); + const mockLogger = mockServices.logger.mock(); + const loggerSpy = jest.spyOn(mockLogger, 'info'); + + const { server } = await startTestBackend({ + features: [scaffolderPlugin, mockLogger.factory], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .set('Authorization', `Bearer ${mockToken}`) + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + expect(loggerSpy).toHaveBeenCalledTimes(6); + expect(loggerSpy).toHaveBeenNthCalledWith( + 2, + 'Scaffolding task for template:default/create-react-app-template created by user:default/mock', + expect.any(Object), + ); + }); + + it('supports fetching a task by ID', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to lookup + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Get the task by ID + response = await request(server).get(`/api/scaffolder/v2/tasks/${taskId}`); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + createdAt: expect.any(String), + createdBy: 'user:default/mock', + id: taskId, + lastHeartbeatAt: expect.any(String), + spec: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + output: {}, + parameters: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + steps: [ + { + action: 'debug:log', + id: 'step-one', + input: { message: 'hello' }, + name: 'First log', + }, + { + action: 'debug:log', + 'backstage:permissions': { tags: ['steps-tag'] }, + id: 'step-two', + input: { message: 'world' }, + name: 'Second log', + }, + ], + templateInfo: { + baseUrl: 'https://dev.azure.com', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + }, + entityRef: 'template:default/create-react-app-template', + }, + user: { + entity: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', + }, + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + parameters: [ + { + properties: { + requiredParameter1: { + description: 'Required parameter 1', + type: 'string', + }, + }, + required: ['requiredParameter1'], + type: 'object', + }, + { + 'backstage:permissions': { tags: ['parameters-tag'] }, + properties: { + requiredParameter2: { + description: 'Required parameter 2', + type: 'string', + }, + }, + required: ['requiredParameter2'], + type: 'object', + }, + ], + steps: [ + { + action: 'debug:log', + id: 'step-one', + input: { message: 'hello' }, + name: 'First log', + }, + { + action: 'debug:log', + 'backstage:permissions': { tags: ['steps-tag'] }, + id: 'step-two', + input: { message: 'world' }, + name: 'Second log', + }, + ], + type: 'website', + }, + }, + ref: 'user:default/mock', + }, + }, + status: 'completed', + }); + expect(response.body.secrets).toBeUndefined(); + }); + + it('supports listing tasks using a filter', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to lookup + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Confirm non-matching `createdBy` filter does not return any results + let { body, status } = await request(server).get( + '/api/scaffolder/v2/tasks?createdBy=user:default/foo&status=completed&limit=1&offset=0&order=desc:created_at', + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Confirm matching `createdBy` filter returns the expected result + ({ body, status } = await request(server).get( + '/api/scaffolder/v2/tasks?createdBy=user:default/mock&status=completed&limit=1&offset=0&order=desc:created_at', + )); + + expect(status).toBe(200); + expect(body).toMatchObject({ + tasks: [ + { + createdAt: expect.any(String), + createdBy: 'user:default/mock', + id: taskId, + lastHeartbeatAt: expect.any(String), + spec: { + apiVersion: 'scaffolder.backstage.io/v1beta3', + output: {}, + parameters: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + steps: [ + { + action: 'debug:log', + id: 'step-one', + input: { message: 'hello' }, + name: 'First log', + }, + { + action: 'debug:log', + 'backstage:permissions': { tags: ['steps-tag'] }, + id: 'step-two', + input: { message: 'world' }, + name: 'Second log', + }, + ], + templateInfo: { + baseUrl: 'https://dev.azure.com', + entity: { + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://dev.azure.com', + }, + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + }, + entityRef: 'template:default/create-react-app-template', + }, + user: { ref: 'user:default/mock' }, + }, + status: 'completed', + }, + ], + totalTasks: 1, + }); + }); + + it('supports canceling a task by ID', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to cancel + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Cancel the task by ID + response = await request(server).post( + `/api/scaffolder/v2/tasks/${taskId}/cancel`, + ); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'cancelled', + }); + }); + + it('supports retrying a task by ID', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to retry + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Retry the task by ID + response = await request(server).post( + `/api/scaffolder/v2/tasks/${taskId}/retry`, + ); + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: taskId }); + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + }); + + it('supports fetching event stream for a task', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to fetch event stream for + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + let statusCode: IncomingMessage['statusCode'] = undefined; + let headers: IncomingMessage['headers'] = {}; + const responseDataFn = jest.fn(); + + // Get event stream for the task + const req = request(server) + .get(`/api/scaffolder/v2/tasks/${taskId}/eventstream`) + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as unknown as IncomingMessage); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toHaveBeenCalledTimes(10); + expect(responseDataFn).toHaveBeenLastCalledWith( + expect.stringContaining(taskId), + ); + expect(responseDataFn).toHaveBeenLastCalledWith( + expect.stringContaining('event: completion'), + ); + }); + + it('supports fetching event stream for a task with after query', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to fetch event stream for + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + let statusCode: IncomingMessage['statusCode'] = undefined; + let headers: IncomingMessage['headers'] = {}; + const responseDataFn = jest.fn(); + + // Get event stream for the task + const req = request(server) + .get(`/api/scaffolder/v2/tasks/${taskId}/eventstream`) + .query({ after: 8 }) // out of 10 events + .set('accept', 'text/event-stream') + .parse((res, _) => { + ({ statusCode, headers } = res as unknown as IncomingMessage); + + res.on('data', chunk => { + responseDataFn(chunk.toString()); + + // the server expects the client to abort the request + if (chunk.includes('completion')) { + req.abort(); + } + }); + }); + + // wait for the request to finish + await req.catch(() => { + // ignore 'aborted' error + }); + + expect(statusCode).toBe(200); + expect(headers['content-type']).toBe('text/event-stream'); + expect(responseDataFn).toHaveBeenCalledTimes(2); + expect(responseDataFn).toHaveBeenNthCalledWith( + 1, + expect.stringContaining(taskId), + ); + expect(responseDataFn).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('event: log'), + ); + expect(responseDataFn).toHaveBeenLastCalledWith( + expect.stringContaining(taskId), + ); + expect(responseDataFn).toHaveBeenLastCalledWith( + expect.stringContaining('event: completion'), + ); + }); + + it('supports fetching events for a task', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to fetch events for + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Get events for the task + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}/events`, + ); + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(10); + expect(response.body).toContainEqual({ + body: { message: 'Starting up task with 2 steps' }, + id: 1, + isTaskRecoverable: false, + taskId: taskId, + type: 'log', + createdAt: expect.any(String), + }); + expect(response.body).toContainEqual({ + body: { message: 'Run completed with status: completed', output: {} }, + id: 10, + isTaskRecoverable: false, + taskId: taskId, + type: 'completion', + createdAt: expect.any(String), + }); + }); + + it('supports fetching events for a task with after query', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + // Confirm no tasks are present + let response = await request(server).get('/api/scaffolder/v2/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ tasks: [], totalTasks: 0 }); + + // Create a task to fetch events for + response = await request(server) + .post('/api/scaffolder/v2/tasks') + .send({ + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ id: expect.any(String) }); + + const { id: taskId } = response.body; + + // Wait for task to complete + await waitForExpect(async () => { + response = await request(server).get( + `/api/scaffolder/v2/tasks/${taskId}`, + ); + + expect(response.body).toMatchObject({ + status: 'completed', + }); + }); + + // Get events for the task + response = await request(server) + .get(`/api/scaffolder/v2/tasks/${taskId}/events`) + .query({ after: 8 }); // out of 10 events + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(2); + expect(response.body).toContainEqual({ + body: { + message: 'Finished step Second log', + status: 'completed', + stepId: 'step-two', + }, + createdAt: expect.any(String), + id: 9, + isTaskRecoverable: false, + taskId: taskId, + type: 'log', + }); + expect(response.body).toContainEqual({ + body: { message: 'Run completed with status: completed', output: {} }, + createdAt: expect.any(String), + id: 10, + isTaskRecoverable: false, + taskId: taskId, + type: 'completion', + }); + }); + + it('supports performing a dry-run of a template', async () => { + const mockTemplate = getMockTemplate(); + + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + const { body, status } = await request(server) + .post('/api/scaffolder/v2/dry-run') + .send({ + template: mockTemplate, + values: { + requiredParameter1: 'required-value-1', + requiredParameter2: 'required-value-2', + }, + directoryContents: [], + }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + log: [ + { body: { message: 'Starting up task with 3 steps' } }, + { + body: { + stepId: 'step-one', + status: 'processing', + message: 'Beginning step First log', + }, + }, + { + body: { + stepId: 'step-one', + message: + '\u001b[32minfo\u001b[39m: Running debug:log in dry-run mode with inputs (secrets redacted): {\n "message": "hello"\n}', + }, + }, + { + body: { + stepId: 'step-one', + message: '\u001b[32minfo\u001b[39m: {\n "message": "hello"\n}', + }, + }, + { + body: { + stepId: 'step-one', + message: '\u001b[32minfo\u001b[39m: hello', + }, + }, + { + body: { + stepId: 'step-one', + status: 'completed', + message: 'Finished step First log', + }, + }, + { + body: { + stepId: 'step-two', + status: 'processing', + message: 'Beginning step Second log', + }, + }, + { + body: { + stepId: 'step-two', + message: + '\u001b[32minfo\u001b[39m: Running debug:log in dry-run mode with inputs (secrets redacted): {\n "message": "world"\n}', + }, + }, + { + body: { + stepId: 'step-two', + message: '\u001b[32minfo\u001b[39m: {\n "message": "world"\n}', + }, + }, + { + body: { + stepId: 'step-two', + message: '\u001b[32minfo\u001b[39m: world', + }, + }, + { + body: { + stepId: 'step-two', + status: 'completed', + message: 'Finished step Second log', + }, + }, + ], + directoryContents: [], + output: {}, + steps: [ + { + id: 'step-one', + name: 'First log', + action: 'debug:log', + input: { message: 'hello' }, + }, + { + id: 'step-two', + name: 'Second log', + action: 'debug:log', + input: { message: 'world' }, + 'backstage:permissions': { tags: ['steps-tag'] }, + }, + ], + }); + expect(getEntityByRefSpy).toHaveBeenCalledTimes(1); + }); + + it('supports performing an autocomplete for a given provider and resource', async () => { + const handleAutocompleteRequest = jest.fn().mockResolvedValue({ + results: [{ title: 'blob' }], + }); + + const mockContext = { mock: 'context' }; + const mockToken = 'mocktoken'; + + const { server } = await startTestBackend({ + features: [ + scaffolderPlugin, + createBackendModule({ + pluginId: 'scaffolder', + moduleId: 'custom-extensions', + register(env) { + env.registerInit({ + deps: { + scaffolder: scaffolderAutocompleteExtensionPoint, + }, + async init({ scaffolder }) { + scaffolder.addAutocompleteProvider({ + id: 'test-provider', + handler: handleAutocompleteRequest, + }); + }, + }); + }, + }), + ], + }); + + const { body, status } = await request(server) + .post('/api/scaffolder/v2/autocomplete/test-provider/my-resource') + .send({ + token: mockToken, + context: mockContext, + }); + + expect(status).toBe(200); + expect(body).toMatchObject({ results: [{ title: 'blob' }] }); + + expect(handleAutocompleteRequest).toHaveBeenCalledWith({ + context: mockContext, + resource: 'my-resource', + token: mockToken, + }); + }); + + it('supports listing templating extensions', async () => { + const { server } = await startTestBackend({ + features: [scaffolderPlugin], + }); + + const { body, status } = await request(server).get( + '/api/scaffolder/v2/templating-extensions', + ); + + expect(status).toBe(200); + expect(body).toMatchObject({ + filters: expect.objectContaining({ + parseRepoUrl: expect.objectContaining({ + description: expect.any(String), + examples: expect.any(Array), + schema: expect.objectContaining({ + input: expect.any(Object), + output: expect.any(Object), + }), + }), + parseEntityRef: expect.objectContaining({ + description: expect.any(String), + examples: expect.any(Array), + schema: expect.objectContaining({ + input: expect.any(Object), + output: expect.any(Object), + }), + }), + pick: expect.objectContaining({ + description: expect.any(String), + examples: expect.any(Array), + schema: expect.objectContaining({ + input: expect.any(Object), + output: expect.any(Object), + }), + }), + projectSlug: expect.objectContaining({ + description: expect.any(String), + examples: expect.any(Array), + schema: expect.objectContaining({ + input: expect.any(Object), + output: expect.any(Object), + }), + }), + }), + globals: { functions: {}, values: {} }, + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts index 3ab6526b4b..c41e6f1aac 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts @@ -18,6 +18,7 @@ import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; import fs from 'fs/promises'; import path from 'path'; import { z as zod } from 'zod'; +import { examples } from './rename.examples'; const contentSchema = (z: typeof zod) => z.object({ @@ -39,6 +40,7 @@ export const createFilesystemReadDirAction = () => { id: 'fs:readdir', description: 'Reads files and directories from the workspace', supportsDryRun: true, + examples, schema: { input: { paths: z => z.array(z.string().min(1)), From 5db28a3201d80d7720cf1d16f188117e1acf12a4 Mon Sep 17 00:00:00 2001 From: solimant Date: Fri, 16 May 2025 18:51:16 +0000 Subject: [PATCH 2/4] Use catalogServiceMock Signed-off-by: solimant --- .../src/next/services/mockCredentials.ts | 26 +- .../src/ScaffolderPlugin.test.ts | 254 +++++++----------- 2 files changed, 111 insertions(+), 169 deletions(-) diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 231c108ac4..617ffdb240 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -111,16 +111,24 @@ export namespace mockCredentials { options?: { actor?: { subject: string } }, ): BackstageCredentials { validateUserEntityRef(userEntityRef); - return { - $$type: '@backstage/BackstageCredentials', - principal: { - type: 'user', - userEntityRef, - ...(options?.actor && { - actor: { type: 'service', subject: options.actor.subject }, - }), + return Object.defineProperty( + { + $$type: '@backstage/BackstageCredentials', + principal: { + type: 'user', + userEntityRef, + ...(options?.actor && { + actor: { type: 'service', subject: options.actor.subject }, + }), + }, }, - }; + 'token', + { + enumerable: false, + configurable: true, + value: user.token(), + }, + ); } /** diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts index 27c00cd1e7..b9cb397ae6 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.test.ts @@ -18,27 +18,20 @@ import request from 'supertest'; import waitForExpect from 'wait-for-expect'; import { createBackendModule } from '@backstage/backend-plugin-api'; -import { CatalogClient } from '@backstage/catalog-client'; import { mockCredentials, mockServices, startTestBackend, } from '@backstage/backend-test-utils'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { scaffolderAutocompleteExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; import { scaffolderPlugin } from './ScaffolderPlugin'; -jest.mock('@backstage/catalog-client'); - describe('scaffolderPlugin', () => { - const getEntityByRefSpy = jest.spyOn( - CatalogClient.prototype, - 'getEntityByRef', - ); - - const getMockTemplate = (): TemplateEntityV1beta3 => ({ + const mockTemplateEntity = { apiVersion: 'scaffolder.backstage.io/v1beta3', kind: 'Template', metadata: { @@ -100,19 +93,16 @@ describe('scaffolderPlugin', () => { }, ], }, - }); - - beforeEach(() => { - getEntityByRefSpy.mockImplementation(async () => getMockTemplate()); - }); - - afterEach(() => { - getEntityByRefSpy.mockReset(); - }); + } satisfies TemplateEntityV1beta3; it('supports fetching template parameters schema', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); const { body, status } = await request(server).get( @@ -198,27 +188,26 @@ describe('scaffolderPlugin', () => { schema: { input: { type: 'object', - required: ['url'], properties: { url: { - title: 'Fetch URL', + type: 'string', description: 'Relative path or absolute URL pointing to the directory tree to fetch', - type: 'string', }, targetPath: { - title: 'Target Path', + type: 'string', description: 'Target path within the working directory to download the contents to.', - type: 'string', }, token: { - title: 'Token', + type: 'string', description: 'An optional token to use for authentication when reading the resources.', - type: 'string', }, }, + required: ['url'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', }, }, }); @@ -239,7 +228,12 @@ describe('scaffolderPlugin', () => { it('rejects creating tasks if template schema definition mismatches', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -266,7 +260,12 @@ describe('scaffolderPlugin', () => { it('supports creating tasks', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -357,76 +356,25 @@ describe('scaffolderPlugin', () => { }, entityRef: 'template:default/create-react-app-template', }, - user: { - entity: { - apiVersion: 'scaffolder.backstage.io/v1beta3', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': 'url:https://dev.azure.com', - }, - description: 'Create a new CRA website project', - name: 'create-react-app-template', - tags: ['experimental', 'react', 'cra'], - title: 'Create React App Template', - }, - spec: { - owner: 'web@example.com', - parameters: [ - { - properties: { - requiredParameter1: { - description: 'Required parameter 1', - type: 'string', - }, - }, - required: ['requiredParameter1'], - type: 'object', - }, - { - 'backstage:permissions': { tags: ['parameters-tag'] }, - properties: { - requiredParameter2: { - description: 'Required parameter 2', - type: 'string', - }, - }, - required: ['requiredParameter2'], - type: 'object', - }, - ], - steps: [ - { - action: 'debug:log', - id: 'step-one', - input: { message: 'hello' }, - name: 'First log', - }, - { - action: 'debug:log', - 'backstage:permissions': { tags: ['steps-tag'] }, - id: 'step-two', - input: { message: 'world' }, - name: 'Second log', - }, - ], - type: 'website', - }, - }, - ref: 'user:default/mock', - }, + user: { ref: 'user:default/mock' }, }, status: 'completed', }); }); it('emits auditlog containing user identifier when backstage auth is passed', async () => { - const mockToken = mockCredentials.user.token(); const mockLogger = mockServices.logger.mock(); + mockLogger.child.mockReturnValue(mockLogger); const loggerSpy = jest.spyOn(mockLogger, 'info'); const { server } = await startTestBackend({ - features: [scaffolderPlugin, mockLogger.factory], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + mockLogger.factory, + ], }); // Confirm no tasks are present @@ -438,7 +386,6 @@ describe('scaffolderPlugin', () => { // Create a task response = await request(server) .post('/api/scaffolder/v2/tasks') - .set('Authorization', `Bearer ${mockToken}`) .send({ templateRef: stringifyEntityRef({ kind: 'template', @@ -466,17 +413,21 @@ describe('scaffolderPlugin', () => { }); }); - expect(loggerSpy).toHaveBeenCalledTimes(6); + expect(loggerSpy).toHaveBeenCalledTimes(10); expect(loggerSpy).toHaveBeenNthCalledWith( - 2, + 3, 'Scaffolding task for template:default/create-react-app-template created by user:default/mock', - expect.any(Object), ); }); it('supports fetching a task by ID', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -561,61 +512,6 @@ describe('scaffolderPlugin', () => { entityRef: 'template:default/create-react-app-template', }, user: { - entity: { - apiVersion: 'scaffolder.backstage.io/v1beta3', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': 'url:https://dev.azure.com', - }, - description: 'Create a new CRA website project', - name: 'create-react-app-template', - tags: ['experimental', 'react', 'cra'], - title: 'Create React App Template', - }, - spec: { - owner: 'web@example.com', - parameters: [ - { - properties: { - requiredParameter1: { - description: 'Required parameter 1', - type: 'string', - }, - }, - required: ['requiredParameter1'], - type: 'object', - }, - { - 'backstage:permissions': { tags: ['parameters-tag'] }, - properties: { - requiredParameter2: { - description: 'Required parameter 2', - type: 'string', - }, - }, - required: ['requiredParameter2'], - type: 'object', - }, - ], - steps: [ - { - action: 'debug:log', - id: 'step-one', - input: { message: 'hello' }, - name: 'First log', - }, - { - action: 'debug:log', - 'backstage:permissions': { tags: ['steps-tag'] }, - id: 'step-two', - input: { message: 'world' }, - name: 'Second log', - }, - ], - type: 'website', - }, - }, ref: 'user:default/mock', }, }, @@ -626,7 +522,12 @@ describe('scaffolderPlugin', () => { it('supports listing tasks using a filter', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -735,7 +636,12 @@ describe('scaffolderPlugin', () => { it('supports canceling a task by ID', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -786,7 +692,12 @@ describe('scaffolderPlugin', () => { it('supports retrying a task by ID', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -846,7 +757,12 @@ describe('scaffolderPlugin', () => { it('supports fetching event stream for a task', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -913,7 +829,12 @@ describe('scaffolderPlugin', () => { it('supports fetching event stream for a task with after query', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -989,7 +910,12 @@ describe('scaffolderPlugin', () => { it('supports fetching events for a task', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -1055,7 +981,12 @@ describe('scaffolderPlugin', () => { it('supports fetching events for a task with after query', async () => { const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); // Confirm no tasks are present @@ -1124,16 +1055,20 @@ describe('scaffolderPlugin', () => { }); it('supports performing a dry-run of a template', async () => { - const mockTemplate = getMockTemplate(); - const { server } = await startTestBackend({ - features: [scaffolderPlugin], + features: [ + scaffolderPlugin, + catalogServiceMock.factory({ + entities: [mockTemplateEntity], + }), + ], }); const { body, status } = await request(server) .post('/api/scaffolder/v2/dry-run') + .auth(mockCredentials.user.token(), { type: 'bearer' }) .send({ - template: mockTemplate, + template: mockTemplateEntity, values: { requiredParameter1: 'required-value-1', requiredParameter2: 'required-value-2', @@ -1230,7 +1165,6 @@ describe('scaffolderPlugin', () => { }, ], }); - expect(getEntityByRefSpy).toHaveBeenCalledTimes(1); }); it('supports performing an autocomplete for a given provider and resource', async () => { From 2285c7fc5b36f1156a03df8ac40ea1c2cd0b0f55 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 9 Jun 2025 11:55:58 +0200 Subject: [PATCH 3/4] chore: fix token tests Signed-off-by: benjdlambert --- .../src/service/router.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 04f619fd02..cdda1dc6cc 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -454,9 +454,12 @@ describe.each([ expect.objectContaining({ createdBy: 'user:default/mock', secrets: { - __initiatorCredentials: JSON.stringify(credentials), + __initiatorCredentials: JSON.stringify({ + ...credentials, + token: mockToken, + }), + backstageToken: mockToken, }, - spec: { apiVersion: mockTemplate.apiVersion, steps: mockTemplate.spec.steps.map((step, index) => ({ @@ -1116,7 +1119,11 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect.objectContaining({ createdBy: 'user:default/mock', secrets: { - __initiatorCredentials: JSON.stringify(credentials), + __initiatorCredentials: JSON.stringify({ + ...credentials, + token: mockCredentials.user.token(), + }), + backstageToken: mockCredentials.user.token(), }, spec: { @@ -1185,7 +1192,11 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect.objectContaining({ createdBy: 'user:default/mock', secrets: { - __initiatorCredentials: JSON.stringify(credentials), + __initiatorCredentials: JSON.stringify({ + ...credentials, + token: mockCredentials.user.token(), + }), + backstageToken: mockCredentials.user.token(), }, spec: { @@ -1273,7 +1284,11 @@ data: {"id":1,"taskId":"a-random-id","type":"completion","createdAt":"","body":{ expect.objectContaining({ createdBy: 'user:default/mock', secrets: { - __initiatorCredentials: JSON.stringify(credentials), + __initiatorCredentials: JSON.stringify({ + ...credentials, + token: mockCredentials.user.token(), + }), + backstageToken: mockCredentials.user.token(), }, spec: { From 12c1fd4539ca0310863136b1761c79cb729adbe9 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 9 Jun 2025 16:39:34 +0200 Subject: [PATCH 4/4] chore: changeset Signed-off-by: benjdlambert --- .changeset/tender-wolves-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-wolves-lie.md diff --git a/.changeset/tender-wolves-lie.md b/.changeset/tender-wolves-lie.md new file mode 100644 index 0000000000..d5c77b44a9 --- /dev/null +++ b/.changeset/tender-wolves-lie.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Make the `user` credentials mock behave more like production