diff --git a/.changeset/tender-peas-smoke.md b/.changeset/tender-peas-smoke.md new file mode 100644 index 0000000000..5c84e1a131 --- /dev/null +++ b/.changeset/tender-peas-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-sentry': patch +--- + +Added examples for `sentry:project:create` scaffolder action and unit tests. diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index 58b39a6b5c..0aa4b5b1c8 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -30,10 +30,12 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^", + "yaml": "^2.3.3" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/types": "workspace:^" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts new file mode 100644 index 0000000000..9d4551c1b5 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Creates a Sentry project with the specified parameters.', + example: yaml.stringify({ + steps: [ + { + id: 'create-sentry-project', + action: 'sentry:project:create', + name: 'Create a Sentry project with provided project slug.', + input: { + organizationSlug: 'my-org', + teamSlug: 'team-a', + name: 'Scaffolded project A', + slug: 'scaff-proj-a', + authToken: + 'a14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df96', + }, + }, + { + id: 'create-sentry-project', + action: 'sentry:project:create', + name: 'Create a Sentry project without providing a project slug.', + input: { + organizationSlug: 'my-org', + teamSlug: 'team-b', + name: 'Scaffolded project B', + authToken: + 'b15711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d4gf93', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts new file mode 100644 index 0000000000..e58f2dc177 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { createSentryCreateProjectAction } from './createProject'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; + +describe('sentry:project:create action', () => { + const createScaffolderConfig = (configData: JsonObject = {}) => ({ + config: new ConfigReader({ + scaffolder: { + ...configData, + }, + }), + }); + + const mockFetch = (response = {}) => { + const mockedResponse = { + status: 201, + headers: { + get: () => 'application/json', + }, + json: async () => + Promise.resolve({ + detail: 'project creation mocked result', + }), + text: async () => Promise.resolve('Unexpected error.'), + ...response, + }; + global.fetch = jest + .fn() + .mockImplementation(() => Promise.resolve(mockedResponse)); + + return mockedResponse; + }; + + const getActionContext = (): ActionContext<{ + organizationSlug: string; + teamSlug: string; + name: string; + slug?: string; + authToken?: string; + }> => ({ + workspacePath: './dev/proj', + createTemporaryDirectory: jest.fn(), + logger: jest.createMockFromModule('winston'), + logStream: jest.createMockFromModule('stream'), + input: { + organizationSlug: 'org', + teamSlug: 'team', + name: 'test project', + authToken: '008hsd7f7123hhdsfhfds7123123881239889fdsaf1g', + }, + output: jest.fn(), + }); + + beforeEach(() => { + mockFetch(); + }); + + test('should request sentry project create with specified parameters.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + await action.handler(actionContext); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${actionContext.input.authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + }), + }, + ); + }); + + test('should request sentry project create with added optional specified project slug', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + actionContext.input = { ...actionContext.input, slug: 'project-slug' }; + + await action.handler(actionContext); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${actionContext.input.authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + slug: actionContext.input.slug, + }), + }, + ); + }); + + test('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { + const sentryScaffolderConfigToken = + 'scaffolder app-config.yaml scaffolder token'; + const action = createSentryCreateProjectAction( + createScaffolderConfig({ + sentry: { + token: sentryScaffolderConfigToken, + }, + }), + ); + + const actionContext = getActionContext(); + + actionContext.input.authToken = undefined; + + await action.handler(actionContext); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${sentryScaffolderConfigToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + }), + }, + ); + }); + + test('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + actionContext.input.authToken = undefined; + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow(new InputError('No valid sentry token given')); + }); + + test('should throw InputError when sentry API returns unexpected content-type.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + const mockedFetchResponse = mockFetch({ + headers: { + get: () => 'text/html', + }, + }); + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow( + new InputError( + `Unexpected Sentry Response Type: ${await mockedFetchResponse.text()}`, + ), + ); + }); + + test('should throw InputError when sentry API returns unexpected status code.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + const mockedFetchResponse = mockFetch({ + status: 400, + }); + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow( + new InputError( + `Sentry Response was: ${(await mockedFetchResponse.json()).detail}`, + ), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index 85a74eda07..504084b9df 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -17,9 +17,10 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; +import { examples } from './createProject.examples'; /** - * Creates the `sentry:craete-project` Scaffolder action. + * Creates the `sentry:create-project` Scaffolder action. * * @remarks * @@ -39,6 +40,7 @@ export function createSentryCreateProjectAction(options: { config: Config }) { authToken?: string; }>({ id: 'sentry:project:create', + examples, schema: { input: { required: ['organizationSlug', 'teamSlug', 'name'], @@ -112,7 +114,7 @@ export function createSentryCreateProjectAction(options: { config: Config }) { const result = await response.json(); if (code !== 201) { - throw new InputError(`Sentry Response was: ${await result.detail}`); + throw new InputError(`Sentry Response was: ${result.detail}`); } ctx.output('id', result.id);