diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 705d5cd..96dd121 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/app-config.yaml b/app-config.yaml new file mode 100644 index 0000000..8ac053a --- /dev/null +++ b/app-config.yaml @@ -0,0 +1,115 @@ +app: + title: Scaffolded Backstage App + baseUrl: http://localhost:3000 + +organization: + name: My Company + +backend: + # Used for enabling authentication, secret is shared by all backend plugins + # See https://backstage.io/docs/auth/service-to-service-auth for + # information on the format + # auth: + # keys: + # - secret: ${BACKEND_SECRET} + baseUrl: http://localhost:7007 + listen: + port: 7007 + # Uncomment the following host directive to bind to specific interfaces + # host: 127.0.0.1 + csp: + connect-src: ["'self'", 'http:', 'https:'] + # Content-Security-Policy directives follow the Helmet format: https://helmetjs.github.io/#reference + # Default Helmet Content-Security-Policy values can be removed by setting the key to false + cors: + origin: http://localhost:3000 + methods: [GET, HEAD, PATCH, POST, PUT, DELETE] + credentials: true + # This is for local development only, it is not recommended to use this in production + # The production database configuration is stored in app-config.production.yaml + database: + client: better-sqlite3 + connection: ':memory:' + # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir + +integrations: + github: + - host: github.com + # This is a Personal Access Token or PAT from GitHub. You can find out how to generate this token, and more information + # about setting up the GitHub integration here: https://backstage.io/docs/getting-started/configuration#setting-up-a-github-integration + token: ${GITHUB_TOKEN} + ### Example for how to add your GitHub Enterprise instance using the API: + # - host: ghe.example.net + # apiBaseUrl: https://ghe.example.net/api/v3 + # token: ${GHE_TOKEN} +pagerDuty: + eventsBaseUrl: 'https://events.pagerduty.com/v2' + # apiToken: ${PAGERDUTY_TOKEN} + oauth: + clientId: ${PAGERDUTY_OAUTH_CLIENT_ID} + clientSecret: ${PAGERDUTY_OAUTH_CLIENT_SECRET} + subDomain: ${PAGERDUTY_OAUTH_SUBDOMAIN} + region: ${PAGERDUTY_OAUTH_REGION} + +# proxy: +# '/pagerduty': +# target: https://api.pagerduty.com +# headers: +# Authorization: Token token=${PAGERDUTY_TOKEN} + ### Example for how to add a proxy endpoint for the frontend. + ### A typical reason to do this is to handle HTTPS and CORS for internal services. + # endpoints: + # '/test': + # target: 'https://example.com' + # changeOrigin: true + +# Reference documentation http://backstage.io/docs/features/techdocs/configuration +# Note: After experimenting with basic setup, use CI/CD to generate docs +# and an external cloud storage when deploying TechDocs for production use-case. +# https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach +techdocs: + builder: 'local' # Alternatives - 'external' + generator: + runIn: 'docker' # Alternatives - 'local' + publisher: + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + +auth: + # see https://backstage.io/docs/auth/ to learn about auth providers + providers: {} + +scaffolder: + # see https://backstage.io/docs/features/software-templates/configuration for software template options + +catalog: + import: + entityFilename: catalog-info.yaml + pullRequestBranchName: backstage-integration + rules: + - allow: [Component, System, API, Resource, Location] + locations: + # Local example data, file locations are relative to the backend process, typically `packages/backend` + - type: file + target: ../../examples/entities.yaml + + # Local example template + - type: file + target: ../../examples/template/template.yaml + rules: + - allow: [Template] + + # Local example organizational data + - type: file + target: ../../examples/org.yaml + rules: + - allow: [User, Group] + + ## Uncomment these lines to add more example data + # - type: url + # target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all.yaml + + ## Uncomment these lines to add an example org + # - type: url + # target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml + # rules: + # - allow: [User, Group] diff --git a/config.d.ts b/config.d.ts index 13e8d16..be21500 100644 --- a/config.d.ts +++ b/config.d.ts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { PagerDutyOAuthConfig } from '@pagerduty/backstage-plugin-common'; + export interface Config { /** * Configuration for the PagerDuty plugin @@ -29,5 +32,10 @@ export interface Config { * @visibility frontend */ apiToken?: string; + /** + * Optional PagerDuty Scoped OAuth Token used in API calls from the backend component. + * @visibility frontend + */ + oauth?: PagerDutyOAuthConfig; }; } diff --git a/package.json b/package.json index 79ae25f..f8fea2a 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,11 @@ "zod": "^3.22.4" }, "peerDependencies": { - "@pagerduty/backstage-plugin-common": "^0.0.2" + "@pagerduty/backstage-plugin-common": "^0.1.0" }, "devDependencies": { "@backstage/cli": "^0.24.0", - "@pagerduty/backstage-plugin-common": "^0.0.2", + "@pagerduty/backstage-plugin-common": "^0.1.0", "@types/node": "^20.9.2", "@types/supertest": "^2.0.12", "@types/webpack-env": "1.18.4", diff --git a/src/apis/pagerduty.test.ts b/src/apis/pagerduty.test.ts index 761ed30..c62aaa0 100644 --- a/src/apis/pagerduty.test.ts +++ b/src/apis/pagerduty.test.ts @@ -2,13 +2,26 @@ import { HttpError, PagerDutyChangeEvent, PagerDutyIncident, PagerDutyIncidentsResponse, PagerDutyService } from "@pagerduty/backstage-plugin-common"; import { createService, createServiceIntegration, getAllEscalationPolicies, getChangeEvents, getIncidents, getOncallUsers, getServiceById, getServiceByIntegrationKey } from "./pagerduty"; +jest.mock("../auth/auth", () => ({ + getAuthToken: jest.fn().mockReturnValue(Promise.resolve('test-token')), + loadAuthConfig: jest.fn().mockReturnValue(Promise.resolve()), +})); + +// jest.spyOn(auth, 'getAuthToken').mockReturnValue(Promise.resolve('test-token')); +// jest.spyOn(auth, 'loadAuthConfig').mockReturnValue(Promise.resolve()); + +const testInputs = [ + "apiToken", + "oauth", +]; + describe("PagerDuty API", () => { afterEach(() => { jest.clearAllMocks(); }); describe("createService", () => { - it("should create a service without event grouping when AIOps is not available", async () => { + it.each(testInputs)("should create a service without event grouping when AIOps is not available", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -41,7 +54,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("should create a service without event grouping when grouping is 'null'", async () => { + it.each(testInputs)("should create a service without event grouping when grouping is 'null'", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -76,7 +89,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("should create a service without event grouping when grouping is undefined", async () => { + it.each(testInputs)("should create a service without event grouping when grouping is undefined", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -111,7 +124,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("should create a service", async () => { + it.each(testInputs)("should create a service", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -146,7 +159,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("should NOT create a service when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT create a service when caller provides invalid arguments", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = ""; @@ -175,7 +188,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT create a service when correct credentials are not provided", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = ""; @@ -204,7 +217,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service when account does not have abilities to perform the action", async () => { + it.each(testInputs)("should NOT create a service when account does not have abilities to perform the action", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -233,7 +246,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service when user is not allowed to view the requested resource", async () => { + it.each(testInputs)("should NOT create a service when user is not allowed to view the requested resource", async () => { const name = "TestService"; const description = "Test Service Description"; const escalationPolicyId = "12345"; @@ -264,7 +277,7 @@ describe("PagerDuty API", () => { }); describe("createServiceIntegration", () => { - it("should create a service integration", async () => { + it.each(testInputs)("should create a service integration", async () => { const serviceId = "serviceId"; const vendorId = "vendorId"; @@ -288,7 +301,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT create a service integration when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT create a service integration when caller provides invalid arguments", async () => { const serviceId = "serviceId"; const vendorId = "nonExistentVendorId"; @@ -307,7 +320,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service integration when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT create a service integration when correct credentials are not provided", async () => { const serviceId = "serviceId"; const vendorId = "nonExistentVendorId"; @@ -326,7 +339,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service integration when user is not allowed to view the requested resource", async () => { + it.each(testInputs)("should NOT create a service integration when user is not allowed to view the requested resource", async () => { const serviceId = "serviceId"; const vendorId = "nonExistentVendorId"; @@ -345,7 +358,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT create a service integration when request rate limit is exceeded", async () => { + it.each(testInputs)("should NOT create a service integration when request rate limit is exceeded", async () => { const serviceId = "serviceId"; const vendorId = "nonExistentVendorId"; @@ -366,7 +379,7 @@ describe("PagerDuty API", () => { }); describe("getAllEscalationPolicies", () => { - it("should return ok", async () => { + it.each(testInputs)("should return ok", async () => { const expectedId = "P0L1CY1D"; const expectedName = "Test Escalation Policy"; @@ -398,7 +411,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT list escalation policies when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT list escalation policies when caller provides invalid arguments", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 400, @@ -417,7 +430,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT list escalation policies when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT list escalation policies when correct credentials are not provided", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 401 @@ -435,7 +448,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT list escalation policies when account does not have abilities to perform the action", async () => { + it.each(testInputs)("should NOT list escalation policies when account does not have abilities to perform the action", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 403 @@ -453,7 +466,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT list escalation policies when user is not allowed to view the requested resource", async () => { + it.each(testInputs)("should NOT list escalation policies when user is not allowed to view the requested resource", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 429 @@ -471,7 +484,7 @@ describe("PagerDuty API", () => { } }); - it("should work with pagination", async () => { + it.each(testInputs)("should work with pagination", async () => { const expectedId = ["P0L1CY1D1", "P0L1CY1D2", "P0L1CY1D3", "P0L1CY1D4", "P0L1CY1D5", "P0L1CY1D6", "P0L1CY1D7", "P0L1CY1D8", "P0L1CY1D9", "P0L1CY1D10"]; const expectedName = ["Test Escalation Policy 1", "Test Escalation Policy 2", "Test Escalation Policy 3", "Test Escalation Policy 4", "Test Escalation Policy 5", "Test Escalation Policy 6", "Test Escalation Policy 7", "Test Escalation Policy 8", "Test Escalation Policy 9", "Test Escalation Policy 10"]; @@ -614,7 +627,7 @@ describe("PagerDuty API", () => { }); describe("getOncallUsers", () => { - it("should return list of users ordered by name ASC from escalation policy level 1", async () => { + it.each(testInputs)("should return list of users ordered by name ASC from escalation policy level 1", async () => { const escalationPolicyId = "12345"; const expectedResponse = [ { @@ -687,7 +700,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should return single user from escalation policy level 1", async () => { + it.each(testInputs)("should return single user from escalation policy level 1", async () => { const escalationPolicyId = "12345"; const expectedResponse = [ { @@ -741,7 +754,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should return list of users ordered by name ASC from other escalation levels when level 1 is empty", async () => { + it.each(testInputs)("should return list of users ordered by name ASC from other escalation levels when level 1 is empty", async () => { const escalationPolicyId = "12345"; const expectedResponse = [ { @@ -814,7 +827,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should return list of users ordered by name ASC without duplicates", async () => { + it.each(testInputs)("should return list of users ordered by name ASC without duplicates", async () => { const escalationPolicyId = "12345"; const expectedResponse = [ { @@ -890,7 +903,7 @@ describe("PagerDuty API", () => { describe("getServices", () => { describe("getServicesByIntegrationKey", () => { - it("should return service when 'integration_key' is provided", async () => { + it.each(testInputs)("should return service when 'integration_key' is provided", async () => { const integrationKey = "INT3GR4T10N_K3Y"; const expectedResponse: PagerDutyService = { id: "S3RV1CE1D", @@ -941,7 +954,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT get service when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT get service when caller provides invalid arguments", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 400, @@ -961,7 +974,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get service when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT get service when correct credentials are not provided", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 401 @@ -980,7 +993,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get service if credentials do not provided the required permissions", async () => { + it.each(testInputs)("should NOT get service if credentials do not provided the required permissions", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 403 @@ -999,7 +1012,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get service if service does not exist", async () => { + it.each(testInputs)("should NOT get service if service does not exist", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 404 @@ -1018,9 +1031,9 @@ describe("PagerDuty API", () => { } }); }); - + describe("getServicesById", () => { - it("should return service when 'service_id' is provided", async () => { + it.each(testInputs)("should return service when 'service_id' is provided", async () => { const serviceId = "SERV1C31D"; const expectedResponse: PagerDutyService = { id: "S3RV1CE1D", @@ -1066,7 +1079,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT get service when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT get service when caller provides invalid arguments", async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -1086,7 +1099,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get service when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT get service when correct credentials are not provided", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 401 @@ -1107,7 +1120,7 @@ describe("PagerDuty API", () => { }); describe("getChangeEvents", () => { - it("should return change events list", async () => { + it.each(testInputs)("should return change events list", async () => { const serviceId = "SERV1C31D"; const expectedResponse: PagerDutyChangeEvent[] = [ { @@ -1128,7 +1141,7 @@ describe("PagerDuty API", () => { type: "github", html_url: "https://example.pagerduty.com/integrations/INT3GR4T10N_1D", } - ] + ] }, { id: "CH4NG3_3V3NT_2D", @@ -1210,7 +1223,7 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT get change events when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT get change events when caller provides invalid arguments", async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -1230,7 +1243,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get service when correct credentials are not provided", async () => { + it.each(testInputs)("should NOT get service when correct credentials are not provided", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 401 @@ -1249,7 +1262,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get change events if credentials do not provide necessary permissions", async () => { + it.each(testInputs)("should NOT get change events if credentials do not provide necessary permissions", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 403 @@ -1270,7 +1283,7 @@ describe("PagerDuty API", () => { }); describe("getIncidents", () => { - it("should return incidents list", async () => { + it.each(testInputs)("should return incidents list", async () => { const serviceId = "SERV1C31D"; const expectedResponse: PagerDutyIncident[] = [ { @@ -1299,7 +1312,7 @@ describe("PagerDuty API", () => { email: "john.doe@email.com", avatar_url: "https://example.pagerduty.com/avatars/123", html_url: "https://example.pagerduty.com/users/123", - } + } } ] }, @@ -1413,9 +1426,9 @@ describe("PagerDuty API", () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it("should NOT get incident when caller provides invalid arguments", async () => { + it.each(testInputs)("should NOT get incident when caller provides invalid arguments", async () => { const serviceId = "SERV1C31D"; - global.fetch = jest.fn(() => + global.fetch = jest.fn().mockReturnValueOnce( Promise.resolve({ status: 400, json: () => Promise.resolve({}) @@ -1433,12 +1446,12 @@ describe("PagerDuty API", () => { } }); - it("should NOT get incidents when correct credentials are not provided", async () => { - global.fetch = jest.fn(() => + it.each(testInputs)("should NOT get incidents when correct credentials are not provided", async () => { + global.fetch = jest.fn().mockReturnValueOnce( Promise.resolve({ status: 401 }) - ) as jest.Mock; + ); const serviceId = "SERV1C31D"; const expectedStatusCode = 401; @@ -1452,12 +1465,12 @@ describe("PagerDuty API", () => { } }); - it("should NOT get incidents if credentials do not provide the required abilities", async () => { - global.fetch = jest.fn(() => + it.each(testInputs)("should NOT get incidents if credentials do not provide the required abilities", async () => { + global.fetch = jest.fn().mockReturnValueOnce( Promise.resolve({ status: 402 }) - ) as jest.Mock; + ); const serviceId = "SERV1C31D"; const expectedStatusCode = 402; @@ -1471,12 +1484,12 @@ describe("PagerDuty API", () => { } }); - it("should NOT get incidents if credentials defined do not have the necessary permissions", async () => { - global.fetch = jest.fn(() => + it.each(testInputs)("should NOT get incidents if credentials defined do not have the necessary permissions", async () => { + global.fetch = jest.fn().mockReturnValueOnce( Promise.resolve({ status: 403 }) - ) as jest.Mock; + ); const serviceId = "SERV1C31D"; const expectedStatusCode = 403; @@ -1490,7 +1503,7 @@ describe("PagerDuty API", () => { } }); - it("should NOT get incidents if PagerDuty REST API limits have been reached", async () => { + it.each(testInputs)("should NOT get incidents if PagerDuty REST API limits have been reached", async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 429 diff --git a/src/apis/pagerduty.ts b/src/apis/pagerduty.ts index e201091..b62680b 100644 --- a/src/apis/pagerduty.ts +++ b/src/apis/pagerduty.ts @@ -1,5 +1,6 @@ +import { getAuthToken } from '../auth/auth'; import { - CreateServiceResponse + CreateServiceResponse, } from '../types'; import { @@ -9,14 +10,14 @@ import { PagerDutyEscalationPoliciesResponse, PagerDutyIntegrationResponse, PagerDutyAbilitiesResponse, - HttpError, PagerDutyOnCallsResponse, PagerDutyUser, PagerDutyService, PagerDutyChangeEventsResponse, PagerDutyChangeEvent, PagerDutyIncident, - PagerDutyIncidentsResponse + PagerDutyIncidentsResponse, + HttpError } from '@pagerduty/backstage-plugin-common'; // Supporting custom actions @@ -126,12 +127,12 @@ export async function createService(name: string, description: string, escalatio break; } } - + const options: RequestInit = { method: 'POST', body: body, headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: await getAuthToken(), 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -192,7 +193,7 @@ export async function createServiceIntegration(serviceId: string, vendorId: stri } }), headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: await getAuthToken(), 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -236,7 +237,7 @@ async function getEscalationPolicies(offset: number, limit: number): Promise<[Bo const options: RequestInit = { method: 'GET', headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: await getAuthToken(), 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -302,7 +303,7 @@ export async function isEventNoiseReductionEnabled(): Promise { const options: RequestInit = { method: 'GET', headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: await getAuthToken(), 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -347,7 +348,7 @@ export async function getOncallUsers(escalationPolicy: string): Promise { let response: Response; const params = `time_zone=UTC&sort_by=created_at&statuses[]=triggered&statuses[]=acknowledged&service_ids[]=${serviceId}`; + const options: RequestInit = { method: 'GET', headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: await getAuthToken(), 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -586,3 +588,4 @@ export async function getIncidents(serviceId: string): Promise { + // check if token already exists and is valid + if ( + (authPersistence.authToken !== '' && + authPersistence.authToken.includes('Bearer') && + authPersistence.authTokenExpiryDate > Date.now()) // case where OAuth token is still valid + || + (authPersistence.authToken !== '' && + authPersistence.authToken.includes('Token'))) { // case where API token is used + return authPersistence.authToken; + } + + await loadAuthConfig(authPersistence.logger, authPersistence.config); + return authPersistence.authToken; +} + +export async function loadAuthConfig(logger: Logger, config: Config) { + try { + // initiliaze the authPersistence in-memory object + authPersistence = { + logger: logger, + config: config, + authToken: '', + authTokenExpiryDate: Date.now() + }; + + if (!config.getOptionalString('pagerDuty.apiToken')) { + logger.warn('No PagerDuty API token found in config file. Trying OAuth token instead...'); + + if (!config.getOptional('pagerDuty.oauth')) { + + logger.error('No PagerDuty OAuth configuration found in config file.'); + throw new Error("No PagerDuty 'apiToken' or 'oauth' configuration found in config file."); + + } else if (!config.getOptionalString('pagerDuty.oauth.clientId') || !config.getOptionalString('pagerDuty.oauth.clientSecret') || !config.getOptionalString('pagerDuty.oauth.subDomain')) { + + logger.error("Missing required PagerDuty OAuth parameters in config file. 'clientId', 'clientSecret', and 'subDomain' are required. 'region' is optional."); + throw new Error('Missing required PagerDuty OAuth parameters in config file.'); + + } else { + + authPersistence.authToken = await getOAuthToken( + config.getString('pagerDuty.oauth.clientId'), + config.getString('pagerDuty.oauth.clientSecret'), + config.getString('pagerDuty.oauth.subDomain'), + config.getOptionalString('pagerDuty.oauth.region') ?? 'us'); + + logger.info('PagerDuty OAuth configuration loaded successfully.'); + } + } else { + authPersistence.authToken = `Token token=${config.getString('pagerDuty.apiToken')}`; + + logger.info('PagerDuty API token loaded successfully.'); + } + } + catch (error) { + logger.error(`Unable to retrieve valid PagerDuty AUTH configuration from config file: ${error}`); + throw error; + } +} + +async function getOAuthToken(clientId: string, clientSecret: string, subDomain: string, region: string): Promise { + // check if required parameters are provided + if (!clientId || !clientSecret || !subDomain) { + throw new Error('Missing required PagerDuty OAuth parameters.'); + } + + // define the scopes required for the OAuth token + const scopes = ` + abilities.read + change_events.read + escalation_policies.read + incidents.read + oncalls.read + schedules.read + services.read + services.write + teams.read + users.read + vendors.read + `; + + // encode the parameters for the request + const urlencoded = new URLSearchParams(); + urlencoded.append("grant_type", "client_credentials"); + urlencoded.append("client_id", clientId); + urlencoded.append("client_secret", clientSecret); + urlencoded.append("scope", `as_account-${region}.${subDomain} ${scopes}`); + + let response: Response; + const options: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: urlencoded, + }; + const baseUrl = 'https://identity.pagerduty.com/oauth/token'; + + try { + response = await fetch(baseUrl, options); + } catch (error) { + throw new Error(`Failed to retrieve oauth token: ${error}`); + } + + switch (response.status) { + case 400: + throw new HttpError("Failed to retrieve valid token. Bad Request - Invalid arguments provided.", 400); + case 401: + throw new HttpError("Failed to retrieve valid token. Forbidden - Invalid credentials provided.", 401); + default: // 200 + break; + } + + const authResponse = await response.json(); + authPersistence.authTokenExpiryDate = Date.now() + (authResponse.expires_in * 1000); + return `Bearer ${authResponse.access_token}`; +} \ No newline at end of file diff --git a/src/service/router.test.ts b/src/service/router.test.ts index 000fca1..9fbccd1 100644 --- a/src/service/router.test.ts +++ b/src/service/router.test.ts @@ -6,6 +6,16 @@ import request from 'supertest'; import { createRouter } from './router'; import { PagerDutyEscalationPolicy, PagerDutyService, PagerDutyServiceResponse, PagerDutyOnCallUsersResponse, PagerDutyChangeEventsResponse, PagerDutyChangeEvent, PagerDutyIncidentsResponse, PagerDutyIncident } from '@pagerduty/backstage-plugin-common'; +jest.mock("../auth/auth", () => ({ + getAuthToken: jest.fn().mockReturnValue(Promise.resolve('test-token')), + loadAuthConfig: jest.fn().mockReturnValue(Promise.resolve()), +})); + +const testInputs = [ + "apiToken", + "oauth", +]; + describe('createRouter', () => { let app: express.Express; @@ -18,7 +28,13 @@ describe('createRouter', () => { baseUrl: 'https://example.com/extra-path', }, pagerDuty: { - apiToken: `${process.env.PAGERDUTY_TOKEN}`, + apiToken: 'test-token', + oauth: { + clientId: 'test-client-id', + clientSecret: 'test-client', + subDomain: 'test-subdomain', + region: 'EU', + } }, }), } @@ -40,7 +56,7 @@ describe('createRouter', () => { }); describe('GET /escalation_policies', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 200, @@ -76,7 +92,7 @@ describe('createRouter', () => { expect(policies.length).toEqual(1); }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 401 @@ -92,7 +108,7 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns empty list when no escalation policies exist', async () => { + it.each(testInputs)('returns empty list when no escalation policies exist', async () => { global.fetch = jest.fn(() => Promise.resolve({ status: 200, @@ -116,7 +132,7 @@ describe('createRouter', () => { }); describe('GET /oncall-users', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { const escalationPolicyId = "12345"; const expectedStatusCode = 200; const expectedResponse: PagerDutyOnCallUsersResponse = { @@ -184,7 +200,7 @@ describe('createRouter', () => { expect(oncallUsersResponse.users.length).toEqual(2); }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { const escalationPolicyId = "12345"; global.fetch = jest.fn(() => Promise.resolve({ @@ -201,7 +217,7 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns empty list when no escalation policies exist', async () => { + it.each(testInputs)('returns empty list when no escalation policies exist', async () => { const escalationPolicyId = "12345"; global.fetch = jest.fn(() => Promise.resolve({ @@ -231,7 +247,7 @@ describe('createRouter', () => { describe('GET /services', () => { describe('with integration key', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { const integrationKey = "INT3GR4T10NK3Y"; const expectedStatusCode = 200; const expectedResponse: PagerDutyServiceResponse = { @@ -288,7 +304,7 @@ describe('createRouter', () => { }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { const integrationKey = "INT3GR4T10NK3Y"; global.fetch = jest.fn(() => Promise.resolve({ @@ -305,7 +321,7 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns NOT FOUND when integration key does not belong to a service', async () => { + it.each(testInputs)('returns NOT FOUND when integration key does not belong to a service', async () => { const integrationKey = "INT3GR4T10NK3Y"; global.fetch = jest.fn(() => Promise.resolve({ @@ -334,7 +350,7 @@ describe('createRouter', () => { }); describe('with service id', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { const serviceId = "SERV1C31D"; const expectedStatusCode = 200; const expectedResponse: PagerDutyServiceResponse = { @@ -383,7 +399,7 @@ describe('createRouter', () => { expect(service).toEqual(expectedResponse); }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -400,7 +416,7 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns NOT FOUND if service id does not exist', async () => { + it.each(testInputs)('returns NOT FOUND if service id does not exist', async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -427,7 +443,7 @@ describe('createRouter', () => { }); describe('change-events', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { const serviceId = "SERV1C31D"; const expectedStatusCode = 200; const expectedResponse: PagerDutyChangeEventsResponse = { @@ -493,7 +509,7 @@ describe('createRouter', () => { expect(changeEvents).toEqual(expectedResponse); }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -510,7 +526,7 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns NOT FOUND if service id does not exist', async () => { + it.each(testInputs)('returns NOT FOUND if service id does not exist', async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -537,7 +553,7 @@ describe('createRouter', () => { }); describe('incidents', () => { - it('returns ok', async () => { + it.each(testInputs)('returns ok', async () => { const serviceId = "SERV1C31D"; const expectedStatusCode = 200; const expectedResponse: PagerDutyIncidentsResponse = { @@ -623,7 +639,7 @@ describe('createRouter', () => { expect(incidents).toEqual(expectedResponse); }); - it('returns unauthorized', async () => { + it.each(testInputs)('returns unauthorized', async () => { const serviceId = "SERV1C31D"; global.fetch = jest.fn(() => Promise.resolve({ @@ -640,21 +656,14 @@ describe('createRouter', () => { expect(response.text).toMatch(expectedErrorMessage); }); - it('returns BAD REQUEST when service id is not provided', async () => { + it.each(testInputs)('returns BAD REQUEST when service id is not provided', async () => { const serviceId = ''; - // global.fetch = jest.fn(() => - // Promise.resolve({ - // status: 401 - // }) - // ) as jest.Mock; const expectedStatusCode = 404; - // const expectedErrorMessage = "Bad Request: 'serviceId' is required"; const response = await request(app).get(`/services/${serviceId}/incidents`); expect(response.status).toEqual(expectedStatusCode); - // expect(response.text).toMatch(expectedErrorMessage); }); }); }); diff --git a/src/service/router.ts b/src/service/router.ts index d1a2784..7b6379e 100644 --- a/src/service/router.ts +++ b/src/service/router.ts @@ -5,6 +5,7 @@ import Router from 'express-promise-router'; import { Logger } from 'winston'; import { getAllEscalationPolicies, getChangeEvents, getIncidents, getOncallUsers, getServiceById, getServiceByIntegrationKey } from '../apis/pagerduty'; import { HttpError, PagerDutyChangeEventsResponse, PagerDutyIncidentsResponse, PagerDutyOnCallUsersResponse, PagerDutyServiceResponse } from '@pagerduty/backstage-plugin-common'; +import { loadAuthConfig } from '../auth/auth'; export interface RouterOptions { logger: Logger; @@ -14,16 +15,10 @@ export interface RouterOptions { export async function createRouter( options: RouterOptions ): Promise { - const { logger, config } = options; - - // Set the PagerDuty API token as an environment variable if it exists in the config file - try { - process.env.PAGERDUTY_TOKEN = config.getString('pagerDuty.apiToken'); - } - catch (error) { - logger.error(`Failed to retrieve PagerDuty API token from config file: ${error}`); - throw error; - } + const { logger, config } = options; + + // Get authentication Config + await loadAuthConfig(logger, config); // Create the router const router = Router(); diff --git a/src/types.ts b/src/types.ts index ec40d64..8715618 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,19 +3,6 @@ export type PagerDutyEscalationPolicyDropDownOption = { value: string; }; -// export class HttpError extends Error { -// constructor(message: string, status: number) { -// super(message); -// this.status = status; -// } - -// status: number; -// } - -// export type PagerDutyAbilitiesListResponse = { -// abilities: string[]; -// }; - export type CreateServiceResponse = { id: string; url: string; diff --git a/yarn.lock b/yarn.lock index e3eb9b0..eb41bff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4745,7 +4745,7 @@ __metadata: "@backstage/config": ^1.1.1 "@backstage/plugin-scaffolder-node": ^0.2.8 "@material-ui/core": ^4.12.4 - "@pagerduty/backstage-plugin-common": ^0.0.2 + "@pagerduty/backstage-plugin-common": ^0.1.0 "@rjsf/core": ^5.14.3 "@types/express": ^4.17.6 "@types/node": ^20.9.2 @@ -4762,14 +4762,14 @@ __metadata: yn: ^4.0.0 zod: ^3.22.4 peerDependencies: - "@pagerduty/backstage-plugin-common": ^0.0.2 + "@pagerduty/backstage-plugin-common": ^0.1.0 languageName: unknown linkType: soft -"@pagerduty/backstage-plugin-common@npm:^0.0.2": - version: 0.0.2 - resolution: "@pagerduty/backstage-plugin-common@npm:0.0.2" - checksum: 6da0a9c1368748752db996fec6d676abfaf5c3cb66fcfdf1920e29ed5ee5f4a9c95b9503671217baec0dd729ac21f98d4b7e8475e7e712721566c4f8c526fb51 +"@pagerduty/backstage-plugin-common@npm:^0.1.0": + version: 0.1.0 + resolution: "@pagerduty/backstage-plugin-common@npm:0.1.0" + checksum: a16768493e91895f67d41d785a79b580d5b00055fb44daedbed81732a43790d8cb884923dbcc8e7c020cf5d3f772d73eb067705c68736863974be80703499041 languageName: node linkType: hard