From 671a424037eba031974f5b16d7a6316698c4f9c8 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Thu, 20 Apr 2023 18:45:16 +0100 Subject: [PATCH] fix(API): Implement authorisation (#103) Fixes #84. # High-level changes - Implement _super admin_ support by using an optional env var (`AUTHORITY_SUPERADMIN`) that specifies the JWT subject id of the super admin (i.e., their email address). - **Existing API tests are now run as a super admin**. - Update test suite for each endpoint to run authorisation checks with every type of user (using test util `testOrgRouteAuth()`). - Anonymous users now get a `401` on any endpoint under `/orgs`. - Regular members aren't allowed to update their own membership for security reasons: they shouldn't be allowed to change anything -- especially their user name. - Regular members aren't allowed to delete their own membership: I can't think of any legitimate use case for it, but I can think of things goes awry if the membership dataset for an organisation goes out of sync with the org's data sources. # Notes - Auth takes precedence over existence checks for security reasons. So, for example, no authenticated user could just request `GET /orgs/bbc.com` to check if `bbc.com` is registered: - A `403` will be returned regardless of whether `bbc.com` exists or not, if the current user isn't an admin of `bbc.com`. For example, if the user is a regular member of `bbc.com` or not even a member at all. - A `200` will be returned if the org exists and the user is an admin of `bbc.com`. - Super admins will get a `200` if the org exists or `404` if it doesn't. # Testing First off, you need an access token to make authenticated requests to anything under `/orgs`. To obtain one for the super admin (`admin@veraid.example`), run: ```http ### Authenticate with authorisation server (client credentials) POST http://mock-authz-server.default.10.103.177.106.sslip.io/default/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=admin@veraid.example&client_secret=s3cr3t ``` Replace the email address above to impersonate any user -- even someone who's not a member of any org. You can now make authenticated requests. For example: ```http ### Create org POST http://veraid-authority.default.10.103.177.106.sslip.io/orgs Authorization: Bearer Content-Type: application/json { "name": "example.com", "memberAccessType": "OPEN" } ### Get org GET http://veraid-authority.default.10.103.177.106.sslip.io/orgs/example.com Authorization: Bearer ``` --- README.md | 26 +- k8s/api-service.yml | 3 + src/api/orgAuthPlugin.ts | 112 +++++++ src/api/routes/awala.routes.spec.ts | 63 ++-- src/api/routes/healthcheck.routes.spec.ts | 7 +- src/api/routes/member.routes.spec.ts | 56 +++- src/api/routes/member.routes.ts | 3 + .../memberKeyImportToken.routes.spec.ts | 14 +- src/api/routes/memberPublicKey.routes.spec.ts | 30 +- src/api/routes/org.routes.spec.ts | 52 +++- src/api/routes/org.routes.ts | 3 + src/api/server.spec.ts | 166 ----------- src/api/server.ts | 32 +- src/backgroundQueue/server.spec.ts | 9 +- .../sinks/example.sink.spec.ts | 14 +- .../memberBundleRequestTrigger.sink.spec.ts | 11 +- src/testUtils/apiServer.ts | 273 +++++++++++++++++- src/testUtils/db.ts | 8 +- src/testUtils/envVars.ts | 6 +- src/testUtils/logging.ts | 2 + src/testUtils/queueServer.ts | 12 +- src/testUtils/server.ts | 30 +- src/testUtils/stubs.ts | 2 +- .../plugins/jwksAuthentication.spec.ts | 153 ++++++++++ .../fastify/plugins/jwksAuthentication.ts | 34 +++ .../fastify/plugins/notFoundHandler.spec.ts | 33 ++- src/utilities/fastify/server.spec.ts | 9 +- src/utilities/fastify/server.ts | 5 + src/utilities/http.ts | 2 + 29 files changed, 856 insertions(+), 314 deletions(-) create mode 100644 src/api/orgAuthPlugin.ts create mode 100644 src/utilities/fastify/plugins/jwksAuthentication.spec.ts create mode 100644 src/utilities/fastify/plugins/jwksAuthentication.ts diff --git a/README.md b/README.md index 4d3f5933..d2e31c31 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,14 @@ All processes require the following variables: - `KMS_ADAPTER` (required; e.g., `AWS`, `GCP`). - Any other variable required by the specific adapter in use. Refer to the [`@relaycorp/webcrypto-kms` documentation](https://www.npmjs.com/package/@relaycorp/webcrypto-kms). -The API server additionally requires the following variables: +The API server additionally uses the following variables: - Authentication-related variables: - `OAUTH2_JWKS_URL` (required). The URL to the JWKS endpoint of the authorisation server. - Either `OAUTH2_TOKEN_ISSUER` or `OAUTH2_TOKEN_ISSUER_REGEX` (required). The (URL of the) authorisation server. - `OAUTH2_TOKEN_AUDIENC[example.sink.spec.ts](src%2FbackgroundQueue%2Fsinks%2Fexample.sink.spec.ts)E` (required). The identifier of the current instance of this server (typically its public URL). +- Authorisation-related variables: + - `AUTHORITY_SUPERADMIN` (optional): The JWT _subject id_ of the super admin, which in this app we require it to be an email address. When unset, routes that require super admin role (e.g., `POST /orgs`) won't work by design. This is desirable in cases where an instance of this server will only ever support a handful of domain names (they could set the `AUTHORITY_SUPERADMIN` to create the orgs, and then unset the super admin var). ## Development @@ -35,12 +37,24 @@ To start the app, simply run: skaffold dev ``` -You can find the URL to the HTTP server by running: +You can find the URL to the HTTP servers by running: ``` -kn service describe veraid-authority -o url +kn service list ``` +To make authenticated requests to the API server, you need to get an access token from the mock authorisation server first. For example, to get an access token for the super admin (`admin@veraid.example`), run: + +```http +### Authenticate with authorisation server (client credentials) +POST http://mock-authz-server.default.10.103.177.106.sslip.io/default/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=admin@veraid.example&client_secret=s3cr3t +``` + +You can then make authenticated requests to the API server by setting the `Authorization` header to `Bearer `. + ## Architecture This multi-tenant server will allow one or more organisations to manage their VeraId setup, and it'll also allow organisation members to claim and renew their VeraId Ids. @@ -49,14 +63,16 @@ This multi-tenant server will allow one or more organisations to manage their Ve ### Authentication and authorisation -We use OAuth2 with JWKS to delegate authentication to an external identity provider. +We use OAuth2 with JWKS to delegate authentication to an external identity provider. We require the JWT token's `sub` claim to be the email address of the user. The API employs the following roles: -- Admin. They can do absolutely anything on any organisation. +- Super admin. They can do absolutely anything on any organisation. - Org admin. They can do anything within their own organisation. - Org member. They can manage much of their own membership in their respective organisation. +Authorisation grant logs use the level `DEBUG` to minimise PII transmission and storage for legal/privacy reasons, whilst denial logs use the level `INFO` for auditing purposes. + ### HTTP Endpoints It will support the following API endpoints, which are to be consumed by the VeraId CA Console (a CLI used by organisation admins) and VeraId signature producers (used by organisation members): diff --git a/k8s/api-service.yml b/k8s/api-service.yml index 1b08a1b8..886a3695 100644 --- a/k8s/api-service.yml +++ b/k8s/api-service.yml @@ -21,6 +21,9 @@ spec: env: - name: AUTHORITY_VERSION value: "1.0.0dev1" + - name: AUTHORITY_SUPERADMIN + value: admin@veraid.example + - name: MONGODB_USERNAME valueFrom: configMapKeyRef: diff --git a/src/api/orgAuthPlugin.ts b/src/api/orgAuthPlugin.ts new file mode 100644 index 00000000..da189586 --- /dev/null +++ b/src/api/orgAuthPlugin.ts @@ -0,0 +1,112 @@ +import { getModelForClass } from '@typegoose/typegoose'; +import envVar from 'env-var'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import fastifyPlugin, { type PluginMetadata } from 'fastify-plugin'; +import type { Connection } from 'mongoose'; + +import { MemberModelSchema, Role } from '../models/Member.model.js'; +import { HTTP_STATUS_CODES } from '../utilities/http.js'; +import type { Result } from '../utilities/result.js'; +import type { PluginDone } from '../utilities/fastify/PluginDone.js'; + +interface OrgRequestParams { + readonly orgName: string; + readonly memberId: string; +} + +interface AuthenticatedFastifyRequest extends FastifyRequest { + user: { sub: string }; +} + +interface AuthorisedFastifyRequest extends AuthenticatedFastifyRequest { + isUserAdmin: boolean; +} + +interface AuthorisationGrant { + readonly isAdmin: boolean; + readonly reason: string; +} + +async function decideAuthorisation( + userEmail: string, + request: FastifyRequest, + dbConnection: Connection, + superAdmin?: string, +): Promise> { + if (superAdmin === userEmail) { + return { didSucceed: true, result: { reason: 'User is super admin', isAdmin: true } }; + } + + const { orgName, memberId } = request.params as Partial; + + if (orgName === undefined) { + return { didSucceed: false, reason: 'Non-super admin tries to access bulk org endpoint' }; + } + + const memberModel = getModelForClass(MemberModelSchema, { + existingConnection: dbConnection, + }); + const member = await memberModel.findOne({ orgName, email: userEmail }).select(['role']); + if (member === null) { + return { didSucceed: false, reason: 'User is not a member of the org' }; + } + if (member.role === Role.ORG_ADMIN) { + return { didSucceed: true, result: { reason: 'User is org admin', isAdmin: true } }; + } + + if (member.id === memberId) { + return { + didSucceed: true, + result: { reason: 'User is accessing their own membership', isAdmin: false }, + }; + } + + return { didSucceed: false, reason: 'User is not accessing their membership' }; +} + +async function denyAuthorisation(reason: string, reply: FastifyReply, userEmail: string) { + reply.log.info({ userEmail, reason }, 'Authorisation denied'); + await reply.code(HTTP_STATUS_CODES.FORBIDDEN).send(); +} + +function registerOrgAuth(fastify: FastifyInstance, _opts: PluginMetadata, done: PluginDone): void { + fastify.addHook('onRequest', fastify.authenticate); + + fastify.decorateRequest('isUserAdmin', false); + + fastify.addHook('onRequest', async (request, reply) => { + const superAdmin = envVar.get('AUTHORITY_SUPERADMIN').asString(); + const userEmail = (request as AuthenticatedFastifyRequest).user.sub; + const decision = await decideAuthorisation(userEmail, request, fastify.mongoose, superAdmin); + const reason = decision.didSucceed ? decision.result.reason : decision.reason; + if (decision.didSucceed) { + (request as AuthorisedFastifyRequest).isUserAdmin = decision.result.isAdmin; + request.log.debug({ userEmail, reason }, 'Authorisation granted'); + } else { + await denyAuthorisation(reason, reply, userEmail); + } + }); + + done(); +} + +/** + * Require the current user to be a super admin or an admin of the current org. + * + * This is defined as type `any` instead of `preParsingHookHandler` because the latter would + * discard the types for all the request parameters (e.g., `request.params`) in the route, since + * `preParsingHookHandler` doesn't offer a generic parameter that honours such parameters. + */ +const requireUserToBeAdmin: any = async ( + request: AuthorisedFastifyRequest, + reply: FastifyReply, +) => { + if (!request.isUserAdmin) { + await denyAuthorisation('User is not an admin', reply, request.user.sub); + } +}; + +const orgAuthPlugin = fastifyPlugin(registerOrgAuth, { name: 'org-auth' }); +export default orgAuthPlugin; + +export { requireUserToBeAdmin }; diff --git a/src/api/routes/awala.routes.spec.ts b/src/api/routes/awala.routes.spec.ts index de231298..9edfbbb4 100644 --- a/src/api/routes/awala.routes.spec.ts +++ b/src/api/routes/awala.routes.spec.ts @@ -1,20 +1,20 @@ import { jest } from '@jest/globals'; +import type { FastifyInstance } from 'fastify'; -import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; import { HTTP_STATUS_CODES } from '../../utilities/http.js'; import { mockSpy } from '../../testUtils/jest.js'; import type { Result } from '../../utilities/result.js'; +import { type MockLogSet, partialPinoLog } from '../../testUtils/logging.js'; +import { generateKeyPair } from '../../testUtils/webcrypto.js'; +import { derSerialisePublicKey } from '../../utilities/webcrypto.js'; +import { MemberPublicKeyImportProblemType } from '../../MemberKeyImportTokenProblemType.js'; +import type { MemberProblemType } from '../../MemberProblemType.js'; import { AWALA_PDA, - MEMBER_KEY_IMPORT_TOKEN, MEMBER_PUBLIC_KEY_MONGO_ID, + MEMBER_KEY_IMPORT_TOKEN, SIGNATURE, } from '../../testUtils/stubs.js'; -import { makeMockLogging, partialPinoLog } from '../../testUtils/logging.js'; -import { generateKeyPair } from '../../testUtils/webcrypto.js'; -import { derSerialisePublicKey } from '../../utilities/webcrypto.js'; -import { MemberPublicKeyImportProblemType } from '../../MemberKeyImportTokenProblemType.js'; -import type { MemberProblemType } from '../../MemberProblemType.js'; const mockProcessMemberKeyImportToken = mockSpy( jest.fn<() => Promise>>(), @@ -38,16 +38,15 @@ const publicKeyBuffer = await derSerialisePublicKey(publicKey); const publicKeyBase64 = publicKeyBuffer.toString('base64'); describe('awala routes', () => { - const mockLogging = makeMockLogging(); - const getTestApiServer = makeTestApiServer(mockLogging.logger); - let serverInstance: FastifyTypedInstance; - + const getTestServerFixture = makeTestApiServer(); + let server: FastifyInstance; + let logs: MockLogSet; beforeEach(() => { - serverInstance = getTestApiServer(); + ({ server, logs } = getTestServerFixture()); }); test('Invalid content type should resolve to unsupported media type error', async () => { - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', @@ -72,7 +71,7 @@ describe('awala routes', () => { }; test('Valid data should be accepted', async () => { - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -84,8 +83,8 @@ describe('awala routes', () => { expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED); expect(mockCreateMemberBundleRequest).toHaveBeenCalledOnceWith(validPayload, { - logger: serverInstance.log, - dbConnection: serverInstance.mongoose, + logger: server.log, + dbConnection: server.mongoose, }); }); @@ -95,7 +94,7 @@ describe('awala routes', () => { memberBundleStartDate: 'INVALID_DATE', }; - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -103,7 +102,7 @@ describe('awala routes', () => { }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockLogging.logs).toContainEqual( + expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, reason: expect.stringContaining('memberBundleStartDate'), @@ -117,7 +116,7 @@ describe('awala routes', () => { awalaPda: 'INVALID_BASE_64', }; - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -125,7 +124,7 @@ describe('awala routes', () => { }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockLogging.logs).toContainEqual( + expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, reason: expect.stringContaining('awalaPda'), @@ -139,7 +138,7 @@ describe('awala routes', () => { signature: 'INVALID_BASE_64', }; - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -147,7 +146,7 @@ describe('awala routes', () => { }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockLogging.logs).toContainEqual( + expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, reason: expect.stringContaining('signature'), @@ -172,7 +171,7 @@ describe('awala routes', () => { didSucceed: true, }); - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -181,8 +180,8 @@ describe('awala routes', () => { expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED); expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, { - logger: serverInstance.log, - dbConnection: serverInstance.mongoose, + logger: server.log, + dbConnection: server.mongoose, }); }); @@ -192,7 +191,7 @@ describe('awala routes', () => { awalaPda: 'INVALID_BASE_64', }; - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -200,7 +199,7 @@ describe('awala routes', () => { }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockLogging.logs).toContainEqual( + expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { reason: expect.stringContaining('awalaPda'), }), @@ -213,7 +212,7 @@ describe('awala routes', () => { publicKeyImportToken: undefined, }; - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -221,7 +220,7 @@ describe('awala routes', () => { }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockLogging.logs).toContainEqual( + expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { reason: expect.stringContaining('publicKeyImportToken'), }), @@ -237,7 +236,7 @@ describe('awala routes', () => { reason, }); - const response = await serverInstance.inject({ + const response = await server.inject({ method: 'POST', url: '/awala', headers: validHeaders, @@ -246,8 +245,8 @@ describe('awala routes', () => { expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, { - logger: serverInstance.log, - dbConnection: serverInstance.mongoose, + logger: server.log, + dbConnection: server.mongoose, }); }); }); diff --git a/src/api/routes/healthcheck.routes.spec.ts b/src/api/routes/healthcheck.routes.spec.ts index c4f79f32..b03fff95 100644 --- a/src/api/routes/healthcheck.routes.spec.ts +++ b/src/api/routes/healthcheck.routes.spec.ts @@ -1,11 +1,14 @@ +// Import Jest, not because we need it, but to work around bug in unstable_mockModule() +import { describe } from '@jest/globals'; + import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; import { makeTestApiServer } from '../../testUtils/apiServer.js'; describe('healthcheck routes', () => { - const getTestServer = makeTestApiServer(); + const getTestServerFixture = makeTestApiServer(); let serverInstance: FastifyTypedInstance; beforeEach(() => { - serverInstance = getTestServer(); + serverInstance = getTestServerFixture().server; }); test('A plain simple HEAD request should provide some diagnostic information', async () => { diff --git a/src/api/routes/member.routes.spec.ts b/src/api/routes/member.routes.spec.ts index 9a79e4b1..cca4bffb 100644 --- a/src/api/routes/member.routes.spec.ts +++ b/src/api/routes/member.routes.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable unicorn/text-encoding-identifier-case */ -import type { InjectOptions } from 'fastify'; +import type { FastifyInstance, InjectOptions } from 'fastify'; import { jest } from '@jest/globals'; import { MEMBER_EMAIL, MEMBER_MONGO_ID, MEMBER_NAME, ORG_NAME } from '../../testUtils/stubs.js'; @@ -14,7 +14,6 @@ import { memberSchemaRoles, type PatchMemberSchema, } from '../../schemas/member.schema.js'; -import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; const mockCreateMember = mockSpy( jest.fn<() => Promise>>(), @@ -30,15 +29,14 @@ jest.unstable_mockModule('../../member.js', () => ({ updateMember: mockUpdateMember, })); -const { makeTestApiServer } = await import('../../testUtils/apiServer.js'); +const { makeTestApiServer, testOrgRouteAuth } = await import('../../testUtils/apiServer.js'); describe('member routes', () => { - const mongoId = '6424ad273f75645b35f9ee79'; const testMemberId = 'TEST_ID'; - const getTestServer = makeTestApiServer(); - let serverInstance: FastifyTypedInstance; + const getTestServerFixture = makeTestApiServer(); + let serverInstance: FastifyInstance; beforeEach(() => { - serverInstance = getTestServer(); + serverInstance = getTestServerFixture().server; }); describe('creation', () => { @@ -47,6 +45,14 @@ describe('member routes', () => { url: `/orgs/${ORG_NAME}/members`, }; + describe('Auth', () => { + const payload: MemberSchema = { role: 'REGULAR' }; + testOrgRouteAuth('ORG', { ...injectionOptions, payload }, getTestServerFixture, { + spy: mockCreateMember, + result: { id: testMemberId }, + }); + }); + test.each(memberSchemaRoles)( 'Minimum required data with role %s should be stored', async (memberSchemaRole: MemberSchemaRole) => { @@ -216,9 +222,16 @@ describe('member routes', () => { describe('get by org name and member id', () => { const injectionOptions: InjectOptions = { method: 'GET', - url: `/orgs/${ORG_NAME}/members/${mongoId}`, + url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}`, }; + describe('Auth', () => { + testOrgRouteAuth('ORG_MEMBERSHIP', injectionOptions, getTestServerFixture, { + spy: mockGetMember, + result: { role: 'REGULAR', name: MEMBER_NAME, email: MEMBER_EMAIL }, + }); + }); + test('Existing member should be returned', async () => { const getMemberSuccessResponse: SuccessfulResult = { didSucceed: true, @@ -233,7 +246,7 @@ describe('member routes', () => { const response = await serverInstance.inject(injectionOptions); - expect(mockGetMember).toHaveBeenCalledWith(ORG_NAME, mongoId, { + expect(mockGetMember).toHaveBeenCalledWith(ORG_NAME, MEMBER_MONGO_ID, { logger: serverInstance.log, dbConnection: serverInstance.mongoose, }); @@ -250,7 +263,7 @@ describe('member routes', () => { const response = await serverInstance.inject(injectionOptions); - expect(mockGetMember).toHaveBeenCalledWith(ORG_NAME, mongoId, { + expect(mockGetMember).toHaveBeenCalledWith(ORG_NAME, MEMBER_MONGO_ID, { logger: serverInstance.log, dbConnection: serverInstance.mongoose, }); @@ -265,6 +278,16 @@ describe('member routes', () => { url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}`, }; + describe('Auth', () => { + beforeEach(() => { + mockGetMember.mockResolvedValueOnce({ didSucceed: true, result: { role: 'REGULAR' } }); + }); + + testOrgRouteAuth('ORG_MEMBERSHIP_RESTRICTED', injectionOptions, getTestServerFixture, { + spy: mockDeleteMember, + }); + }); + test('Valid org name and member id should be accepted', async () => { mockGetMember.mockResolvedValueOnce({ didSucceed: true, @@ -315,6 +338,19 @@ describe('member routes', () => { }, }; + describe('Auth', () => { + beforeEach(() => { + mockGetMember.mockResolvedValueOnce({ didSucceed: true, result: { role: 'REGULAR' } }); + }); + + testOrgRouteAuth( + 'ORG_MEMBERSHIP_RESTRICTED', + { ...injectionOptions, payload: {} }, + getTestServerFixture, + { spy: mockUpdateMember }, + ); + }); + test('Empty parameters should be allowed', async () => { mockGetMember.mockResolvedValueOnce(getMemberSuccessResponse); mockUpdateMember.mockResolvedValueOnce({ diff --git a/src/api/routes/member.routes.ts b/src/api/routes/member.routes.ts index 7006e7c1..5669edad 100644 --- a/src/api/routes/member.routes.ts +++ b/src/api/routes/member.routes.ts @@ -4,6 +4,7 @@ import { createMember, deleteMember, getMember, updateMember } from '../../membe import { MemberProblemType } from '../../MemberProblemType.js'; import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; import type { RouteOptions } from '../../utilities/fastify/RouteOptions.js'; +import { requireUserToBeAdmin } from '../orgAuthPlugin.js'; import memberPublicKeyRoutes from './memberPublicKey.routes.js'; import memberKeyImportToken from './memberKeyImportToken.routes.js'; @@ -120,6 +121,7 @@ export default async function registerRoutes( fastify.route({ method: ['DELETE'], url: '/:memberId', + preParsing: requireUserToBeAdmin, schema: { params: MEMBER_ROUTE_PARAMS, @@ -149,6 +151,7 @@ export default async function registerRoutes( fastify.route({ method: ['PATCH'], url: '/:memberId', + preParsing: requireUserToBeAdmin, schema: { params: MEMBER_ROUTE_PARAMS, diff --git a/src/api/routes/memberKeyImportToken.routes.spec.ts b/src/api/routes/memberKeyImportToken.routes.spec.ts index e32b3b57..645b4fce 100644 --- a/src/api/routes/memberKeyImportToken.routes.spec.ts +++ b/src/api/routes/memberKeyImportToken.routes.spec.ts @@ -21,13 +21,13 @@ jest.unstable_mockModule('../../memberKeyImportToken.js', () => ({ createMemberKeyImportToken: mockCreateMemberKeyImportToken, processMemberKeyImportToken: jest.fn(), })); -const { makeTestApiServer } = await import('../../testUtils/apiServer.js'); +const { makeTestApiServer, testOrgRouteAuth } = await import('../../testUtils/apiServer.js'); describe('member key import token routes', () => { - const getTestServer = makeTestApiServer(); + const getTestServerFixture = makeTestApiServer(); let serverInstance: FastifyTypedInstance; beforeEach(() => { - serverInstance = getTestServer(); + serverInstance = getTestServerFixture().server; }); describe('creation', () => { @@ -36,6 +36,14 @@ describe('member key import token routes', () => { url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-key-import-tokens`, }; + describe('Auth', () => { + const payload: MemberKeyImportTokenSchema = { serviceOid: TEST_SERVICE_OID }; + testOrgRouteAuth('ORG_MEMBERSHIP', { ...injectionOptions, payload }, getTestServerFixture, { + spy: mockCreateMemberKeyImportToken, + result: { id: MEMBER_KEY_IMPORT_TOKEN }, + }); + }); + test('Valid data should be stored', async () => { const payload: MemberKeyImportTokenSchema = { serviceOid: TEST_SERVICE_OID, diff --git a/src/api/routes/memberPublicKey.routes.spec.ts b/src/api/routes/memberPublicKey.routes.spec.ts index d71c8b21..8e66d2f1 100644 --- a/src/api/routes/memberPublicKey.routes.spec.ts +++ b/src/api/routes/memberPublicKey.routes.spec.ts @@ -32,16 +32,16 @@ jest.unstable_mockModule('../../memberPublicKey.js', () => ({ getMemberPublicKey: mockGetMemberPublicKey, deleteMemberPublicKey: mockDeleteMemberPublicKey, })); -const { makeTestApiServer } = await import('../../testUtils/apiServer.js'); +const { makeTestApiServer, testOrgRouteAuth } = await import('../../testUtils/apiServer.js'); const { publicKey } = await generateKeyPair(); const publicKeyBuffer = await derSerialisePublicKey(publicKey); const publicKeyBase64 = publicKeyBuffer.toString('base64'); describe('member public keys routes', () => { - const getTestServer = makeTestApiServer(); + const getTestServerFixture = makeTestApiServer(); let serverInstance: FastifyTypedInstance; beforeEach(() => { - serverInstance = getTestServer(); + serverInstance = getTestServerFixture().server; }); describe('creation', () => { @@ -50,6 +50,17 @@ describe('member public keys routes', () => { url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-keys`, }; + describe('Auth', () => { + const payload: MemberPublicKeySchema = { + serviceOid: TEST_SERVICE_OID, + publicKey: publicKeyBase64, + }; + testOrgRouteAuth('ORG_MEMBERSHIP', { ...injectionOptions, payload }, getTestServerFixture, { + spy: mockCreateMemberPublicKey, + result: { id: PUBLIC_KEY_ID }, + }); + }); + test('Valid data should be stored', async () => { const payload: MemberPublicKeySchema = { serviceOid: TEST_SERVICE_OID, @@ -115,6 +126,19 @@ describe('member public keys routes', () => { url: `/orgs/${ORG_NAME}/members/${MEMBER_MONGO_ID}/public-keys/${PUBLIC_KEY_ID}`, }; + describe('Auth', () => { + beforeEach(() => { + mockGetMemberPublicKey.mockResolvedValueOnce({ + didSucceed: true, + result: { publicKey: publicKeyBase64, serviceOid: TEST_SERVICE_OID }, + }); + }); + + testOrgRouteAuth('ORG_MEMBERSHIP', injectionOptions, getTestServerFixture, { + spy: mockDeleteMemberPublicKey, + }); + }); + test('Valid id should be accepted', async () => { mockGetMemberPublicKey.mockResolvedValueOnce({ didSucceed: true, diff --git a/src/api/routes/org.routes.spec.ts b/src/api/routes/org.routes.spec.ts index f314bd9d..9402fe11 100644 --- a/src/api/routes/org.routes.spec.ts +++ b/src/api/routes/org.routes.spec.ts @@ -10,9 +10,9 @@ import { } from '../../testUtils/stubs.js'; import { type OrgSchema, - type OrgSchemaPatch, type OrgSchemaMemberAccessType, orgSchemaMemberAccessTypes, + type OrgSchemaPatch, } from '../../schemas/org.schema.js'; import type { OrgCreationResult } from '../../orgTypes.js'; import type { Result, SuccessfulResult } from '../../utilities/result.js'; @@ -32,13 +32,14 @@ jest.unstable_mockModule('../../org.js', () => ({ deleteOrg: mockDeleteOrg, })); -const { makeTestApiServer } = await import('../../testUtils/apiServer.js'); +const { makeTestApiServer, testOrgRouteAuth } = await import('../../testUtils/apiServer.js'); describe('org routes', () => { - const getTestServer = makeTestApiServer(); + const getTestServerFixture = makeTestApiServer(); let serverInstance: FastifyTypedInstance; beforeEach(() => { - serverInstance = getTestServer(); + const fixture = getTestServerFixture(); + serverInstance = fixture.server; }); describe('creation', () => { @@ -47,6 +48,14 @@ describe('org routes', () => { url: '/orgs', }; + describe('Auth', () => { + const payload: OrgSchema = { name: ORG_NAME, memberAccessType: 'INVITE_ONLY' }; + testOrgRouteAuth('ORG_BULK', { ...injectionOptions, payload }, getTestServerFixture, { + spy: mockCreateOrg, + result: { name: ORG_NAME }, + }); + }); + test.each([ ['ASCII', ORG_NAME], ['Non ASCII', NON_ASCII_ORG_NAME], @@ -223,6 +232,16 @@ describe('org routes', () => { }, } as const; + describe('Auth', () => { + beforeEach(() => { + mockGetOrg.mockResolvedValueOnce(getOrgSuccessResponse); + }); + + testOrgRouteAuth('ORG', { ...injectionOptions, payload: {} }, getTestServerFixture, { + spy: mockUpdateOrg, + }); + }); + test('Empty parameters should be accepted', async () => { const payload: OrgSchemaPatch = {}; mockGetOrg.mockResolvedValueOnce(getOrgSuccessResponse); @@ -337,6 +356,15 @@ describe('org routes', () => { method: 'GET', }; + describe('Auth', () => { + testOrgRouteAuth( + 'ORG', + { ...injectionOptions, url: `/orgs/${ORG_NAME}` }, + getTestServerFixture, + { spy: mockGetOrg, result: { name: ORG_NAME, memberAccessType: 'INVITE_ONLY' } }, + ); + }); + test.each([ ['ASCII', ORG_NAME], ['Non ASCII', NON_ASCII_ORG_NAME], @@ -391,6 +419,22 @@ describe('org routes', () => { method: 'DELETE', }; + describe('Auth', () => { + beforeEach(() => { + mockGetOrg.mockResolvedValueOnce({ + didSucceed: true, + result: { name: ORG_NAME, memberAccessType: 'INVITE_ONLY' }, + }); + }); + + testOrgRouteAuth( + 'ORG', + { ...injectionOptions, url: `/orgs/${ORG_NAME}` }, + getTestServerFixture, + { spy: mockDeleteOrg }, + ); + }); + test('Valid name should be accepted', async () => { mockGetOrg.mockResolvedValueOnce({ didSucceed: true, diff --git a/src/api/routes/org.routes.ts b/src/api/routes/org.routes.ts index a8ecc62e..75cbf4f5 100644 --- a/src/api/routes/org.routes.ts +++ b/src/api/routes/org.routes.ts @@ -4,6 +4,7 @@ import { createOrg, deleteOrg, getOrg, updateOrg } from '../../org.js'; import { OrgProblemType } from '../../OrgProblemType.js'; import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; import type { RouteOptions } from '../../utilities/fastify/RouteOptions.js'; +import orgAuthPlugin from '../orgAuthPlugin.js'; import memberRoutes from './member.routes.js'; @@ -45,6 +46,8 @@ export default async function registerRoutes( fastify: FastifyTypedInstance, opts: RouteOptions, ): Promise { + await fastify.register(orgAuthPlugin); + fastify.route({ method: ['POST'], url: '/orgs', diff --git a/src/api/server.spec.ts b/src/api/server.spec.ts index 8096d13c..64bd0c3b 100644 --- a/src/api/server.spec.ts +++ b/src/api/server.spec.ts @@ -1,30 +1,13 @@ import { jest } from '@jest/globals'; -import envVar from 'env-var'; -import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; -import fastifyOauth2Verify from 'fastify-auth0-verify'; import pino from 'pino'; -import { configureMockEnvVars } from '../testUtils/envVars.js'; -import { OAUTH2_JWKS_URL, OAUTH2_TOKEN_AUDIENCE, OAUTH2_TOKEN_ISSUER } from '../testUtils/authn.js'; -import { getMockContext, mockSpy } from '../testUtils/jest.js'; - const mockFastify = Symbol('Mock server'); jest.unstable_mockModule('../utilities/fastify/server.js', () => ({ makeFastify: jest.fn<() => Promise>().mockResolvedValue(mockFastify), })); -const mockMakeLogger = jest.fn().mockReturnValue({}); -jest.unstable_mockModule('../utilities/logging.js', () => ({ makeLogger: mockMakeLogger })); - const { makeApiServer } = await import('./server.js'); const { makeFastify } = await import('../utilities/fastify/server.js'); -const { REQUIRED_API_ENV_VARS } = await import('../testUtils/apiServer.js'); - -const mockEnvVars = configureMockEnvVars(REQUIRED_API_ENV_VARS); - -afterAll(() => { - jest.restoreAllMocks(); -}); describe('makeApiServer', () => { test('No logger should be passed by default', async () => { @@ -46,153 +29,4 @@ describe('makeApiServer', () => { expect(serverInstance).toBe(mockFastify); }); - - describe('OAuth2 plugin', () => { - const mockFastifySubcontext: FastifyInstance = { - register: mockSpy(jest.fn()), - } as any; - - async function runAppPlugin(): Promise { - const [plugin] = getMockContext(makeFastify).lastCall as [FastifyPluginAsync]; - await plugin(mockFastifySubcontext, {}); - } - - test('OAUTH2_JWKS_URL should be defined', async () => { - mockEnvVars({ ...REQUIRED_API_ENV_VARS, OAUTH2_JWKS_URL: undefined }); - await makeApiServer(); - - await expect(runAppPlugin).rejects.toThrowWithMessage(envVar.EnvVarError, /OAUTH2_JWKS_URL/u); - }); - - test('OAUTH2_JWKS_URL should be used as the domain', async () => { - await makeApiServer(); - - await runAppPlugin(); - - expect(mockFastifySubcontext.register).toHaveBeenCalledWith( - fastifyOauth2Verify, - expect.objectContaining({ domain: OAUTH2_JWKS_URL }), - ); - }); - - test('OAUTH2_JWKS_URL should be a well-formed URL', async () => { - const malformedUrl = 'not a url'; - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_JWKS_URL: malformedUrl, - }); - await makeApiServer(); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - envVar.EnvVarError, - /OAUTH2_JWKS_URL/u, - ); - }); - - test('OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', async () => { - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_TOKEN_ISSUER: undefined, - OAUTH2_TOKEN_ISSUER_REGEX: undefined, - }); - await makeApiServer(); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - Error, - 'Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', - ); - }); - - test('Both OAUTH2_TOKEN_ISSUER and OAUTH2_TOKEN_ISSUER_REGEX should not be set', async () => { - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_TOKEN_ISSUER_REGEX: OAUTH2_TOKEN_ISSUER, - }); - await makeApiServer(); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - Error, - 'Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', - ); - }); - - test('OAUTH2_TOKEN_ISSUER should be used as the issuer if set', async () => { - await makeApiServer(); - - await runAppPlugin(); - - expect(mockFastifySubcontext.register).toHaveBeenCalledWith( - fastifyOauth2Verify, - expect.objectContaining({ issuer: OAUTH2_TOKEN_ISSUER }), - ); - }); - - test('OAUTH2_TOKEN_ISSUER should be a well-formed URL', async () => { - const malformedUrl = 'not a url'; - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_TOKEN_ISSUER: malformedUrl, - }); - await makeApiServer(); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - envVar.EnvVarError, - /OAUTH2_TOKEN_ISSUER/u, - ); - }); - - test('OAUTH2_TOKEN_ISSUER_REGEX should be used as the issuer if set', async () => { - const issuerRegex = '^this is a regex$'; - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_TOKEN_ISSUER: undefined, - OAUTH2_TOKEN_ISSUER_REGEX: issuerRegex, - }); - await makeApiServer(); - - await runAppPlugin(); - - expect(mockFastifySubcontext.register).toHaveBeenCalledWith( - fastifyOauth2Verify, - expect.objectContaining({ issuer: new RegExp(issuerRegex, 'u') }), - ); - }); - - test('OAUTH2_TOKEN_ISSUER_REGEX should be a well-formed regex', async () => { - const malformedRegex = '['; - mockEnvVars({ - ...REQUIRED_API_ENV_VARS, - OAUTH2_TOKEN_ISSUER: undefined, - OAUTH2_TOKEN_ISSUER_REGEX: malformedRegex, - }); - await makeApiServer(); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - envVar.EnvVarError, - /OAUTH2_TOKEN_ISSUER_REGEX/u, - ); - }); - - test('OAUTH2_TOKEN_AUDIENCE should be defined', async () => { - await makeApiServer(); - - mockEnvVars({ ...REQUIRED_API_ENV_VARS, OAUTH2_TOKEN_AUDIENCE: undefined }); - - await expect(runAppPlugin()).rejects.toThrowWithMessage( - envVar.EnvVarError, - /OAUTH2_TOKEN_AUDIENCE/u, - ); - }); - - test('OAUTH2_TOKEN_AUDIENCE should be used as the audience', async () => { - await makeApiServer(); - - await runAppPlugin(); - - expect(mockFastifySubcontext.register).toHaveBeenCalledWith( - fastifyOauth2Verify, - expect.objectContaining({ audience: OAUTH2_TOKEN_AUDIENCE }), - ); - }); - }); }); diff --git a/src/api/server.ts b/src/api/server.ts index b8f1a6ce..c0753de7 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,12 +1,9 @@ import type { FastifyInstance, FastifyPluginCallback } from 'fastify'; import type { BaseLogger } from 'pino'; -import fastifyRoutes from '@fastify/routes'; -import fastifyOauth2Verify, { type FastifyAuth0VerifyOptions } from 'fastify-auth0-verify'; -import env from 'env-var'; import { makeFastify } from '../utilities/fastify/server.js'; import type { RouteOptions } from '../utilities/fastify/RouteOptions.js'; -import notFoundHandler from '../utilities/fastify/plugins/notFoundHandler.js'; +import jwksPlugin from '../utilities/fastify/plugins/jwksAuthentication.js'; import healthcheckRoutes from './routes/healthcheck.routes.js'; import orgRoutes from './routes/org.routes.js'; @@ -18,33 +15,8 @@ const ROOT_ROUTES: FastifyPluginCallback[] = [ awalaRoutes, ]; -function getOauth2PluginOptions(): FastifyAuth0VerifyOptions { - const audience = env.get('OAUTH2_TOKEN_AUDIENCE').required().asString(); - const domain = env.get('OAUTH2_JWKS_URL').required().asUrlString(); - - const issuer = env.get('OAUTH2_TOKEN_ISSUER').asUrlString(); - const issuerRegex = env.get('OAUTH2_TOKEN_ISSUER_REGEX').asRegExp('u'); - if ( - (issuer === undefined && issuerRegex === undefined) || - (issuer !== undefined && issuerRegex !== undefined) - ) { - throw new Error('Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set'); - } - - return { - audience, - domain, - issuer: issuer ?? (issuerRegex as any), - }; -} - async function makeApiServerPlugin(server: FastifyInstance): Promise { - await server.register(fastifyRoutes); - await server.register(notFoundHandler); - - const verifyOptions = getOauth2PluginOptions(); - await server.register(fastifyOauth2Verify, verifyOptions); - + await server.register(jwksPlugin); await Promise.all(ROOT_ROUTES.map((route) => server.register(route))); } diff --git a/src/backgroundQueue/server.spec.ts b/src/backgroundQueue/server.spec.ts index 977405ec..4dbe4b66 100644 --- a/src/backgroundQueue/server.spec.ts +++ b/src/backgroundQueue/server.spec.ts @@ -1,21 +1,18 @@ import { CloudEvent } from 'cloudevents'; import type { FastifyInstance } from 'fastify'; -import { configureMockEnvVars } from '../testUtils/envVars.js'; -import { REQUIRED_QUEUE_ENV_VARS, setUpTestQueueServer } from '../testUtils/queueServer.js'; +import { setUpTestQueueServer } from '../testUtils/queueServer.js'; import { HTTP_STATUS_CODES } from '../utilities/http.js'; import { CE_ID, CE_SOURCE } from '../testUtils/eventing/stubs.js'; import { postEvent } from '../testUtils/eventing/cloudEvents.js'; import { QueueProblemType } from './QueueProblemType.js'; -configureMockEnvVars(REQUIRED_QUEUE_ENV_VARS); - describe('makeQueueServer', () => { - const getServer = setUpTestQueueServer(); + const getTestServerFixture = setUpTestQueueServer(); let server: FastifyInstance; beforeEach(() => { - server = getServer(); + ({ server } = getTestServerFixture()); }); describe('GET', () => { diff --git a/src/backgroundQueue/sinks/example.sink.spec.ts b/src/backgroundQueue/sinks/example.sink.spec.ts index 93d78955..ca349a2f 100644 --- a/src/backgroundQueue/sinks/example.sink.spec.ts +++ b/src/backgroundQueue/sinks/example.sink.spec.ts @@ -1,22 +1,18 @@ import { CloudEvent } from 'cloudevents'; -import { configureMockEnvVars } from '../../testUtils/envVars.js'; import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; -import { REQUIRED_QUEUE_ENV_VARS, setUpTestQueueServer } from '../../testUtils/queueServer.js'; +import { setUpTestQueueServer } from '../../testUtils/queueServer.js'; import { HTTP_STATUS_CODES } from '../../utilities/http.js'; -import { makeMockLogging, partialPinoLog } from '../../testUtils/logging.js'; +import { partialPinoLog } from '../../testUtils/logging.js'; import { CE_ID, CE_SOURCE } from '../../testUtils/eventing/stubs.js'; import { postEvent } from '../../testUtils/eventing/cloudEvents.js'; import { EXAMPLE_TYPE, type ExampleEventPayload } from '../../events/example.event.js'; describe('example event publisher routes', () => { - configureMockEnvVars(REQUIRED_QUEUE_ENV_VARS); - - const mockLogging = makeMockLogging(); - const getTestServer = setUpTestQueueServer(mockLogging.logger); + const getTestServerFixture = setUpTestQueueServer(); let server: FastifyTypedInstance; beforeEach(() => { - server = getTestServer(); + ({ server } = getTestServerFixture()); }); test('Event should be processed', async () => { @@ -30,7 +26,7 @@ describe('example event publisher routes', () => { const response = await postEvent(event, server); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.NO_CONTENT); - expect(mockLogging.logs).toContainEqual( + expect(getTestServerFixture().logs).toContainEqual( partialPinoLog('info', 'Event processed', { event: event.toJSON() }), ); }); diff --git a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts index b9ea4465..bbf86b23 100644 --- a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts +++ b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts @@ -1,9 +1,7 @@ import { CloudEvent } from 'cloudevents'; -import { configureMockEnvVars } from '../../testUtils/envVars.js'; import type { FastifyTypedInstance } from '../../utilities/fastify/FastifyTypedInstance.js'; -import { REQUIRED_QUEUE_ENV_VARS, setUpTestQueueServer } from '../../testUtils/queueServer.js'; -import { makeMockLogging } from '../../testUtils/logging.js'; +import { setUpTestQueueServer } from '../../testUtils/queueServer.js'; import { CE_ID, CE_SOURCE } from '../../testUtils/eventing/stubs.js'; import { postEvent } from '../../testUtils/eventing/cloudEvents.js'; import { @@ -19,13 +17,10 @@ import { describe('triggerBundleRequest', () => { const getEvents = mockEmitter(); - configureMockEnvVars(REQUIRED_QUEUE_ENV_VARS); - - const mockLogging = makeMockLogging(); - const getTestServer = setUpTestQueueServer(mockLogging.logger); + const getTestServerFixture = setUpTestQueueServer(); let server: FastifyTypedInstance; beforeEach(() => { - server = getTestServer(); + ({ server } = getTestServerFixture()); }); test('New events should be fired', async () => { diff --git a/src/testUtils/apiServer.ts b/src/testUtils/apiServer.ts index 005c4ca5..a99bb898 100644 --- a/src/testUtils/apiServer.ts +++ b/src/testUtils/apiServer.ts @@ -1,20 +1,275 @@ -import type { BaseLogger } from 'pino'; +import { jest } from '@jest/globals'; +import { getModelForClass } from '@typegoose/typegoose'; +import type { + FastifyInstance, + InjectOptions, + LightMyRequestResponse, + onRequestAsyncHookHandler, + onRequestHookHandler, +} from 'fastify'; +import fastifyPlugin, { type PluginMetadata } from 'fastify-plugin'; -import type { FastifyTypedInstance } from '../utilities/fastify/FastifyTypedInstance.js'; -import { makeApiServer } from '../api/server.js'; +import type { PluginDone } from '../utilities/fastify/PluginDone.js'; +import { HTTP_STATUS_CODES } from '../utilities/http.js'; +import { MemberModelSchema, Role } from '../models/Member.model.js'; +import type { Result, SuccessfulResult } from '../utilities/result.js'; -import { makeTestServer } from './server.js'; +import { makeTestServer, type TestServerFixture } from './server.js'; import { OAUTH2_JWKS_URL, OAUTH2_TOKEN_AUDIENCE, OAUTH2_TOKEN_ISSUER } from './authn.js'; -import { configureMockEnvVars, REQUIRED_ENV_VARS } from './envVars.js'; +import { REQUIRED_ENV_VARS } from './envVars.js'; +import { getMockInstance } from './jest.js'; +import { partialPinoLog } from './logging.js'; +import { MEMBER_EMAIL, MEMBER_MONGO_ID, MEMBER_NAME, ORG_NAME } from './stubs.js'; -export const REQUIRED_API_ENV_VARS = { +const ORG_MEMBER: MemberModelSchema = { + orgName: ORG_NAME, + name: MEMBER_NAME, + role: Role.REGULAR, + email: MEMBER_EMAIL, +}; + +const ORG_ADMIN_EMAIL = `admin@${ORG_NAME}`; +const ORG_ADMIN = { + orgName: ORG_NAME, + name: 'admin', + role: Role.ORG_ADMIN, + email: ORG_ADMIN_EMAIL, +}; + +function mockJwksAuthentication( + fastify: FastifyInstance, + _opts: PluginMetadata, + done: PluginDone, +): void { + fastify.decorate( + 'authenticate', + jest.fn().mockImplementation((_request, _reply, handlerDone) => { + handlerDone(); + }), + ); + + done(); +} +jest.unstable_mockModule('../../utilities/fastify/plugins/jwksAuthentication.js', () => ({ + default: fastifyPlugin(mockJwksAuthentication, { name: 'mock-jwks-authentication' }), +})); +const { makeApiServer } = await import('../api/server.js'); + +const SUPER_ADMIN_EMAIL = 'admin@veraid-authority.example'; + +/** + * Extract the `authenticate` decorator from the child context of `fastify` where the JWKS plugin + * is registered. + */ +function getMockAuthenticateFromServer(fastify: FastifyInstance) { + const childrenSymbol = Object.getOwnPropertySymbols(fastify).find( + (symbol) => symbol.description === 'fastify.children', + ); + if (childrenSymbol === undefined) { + throw new Error('Could not find children property'); + } + + // @ts-expect-error: Allow lookup by symbol + const children = fastify[childrenSymbol] as FastifyInstance[]; + + const childContext = Object.values(children).find((value) => 'authenticate' in value); + if (childContext === undefined) { + throw new Error('Could not find child context'); + } + const { authenticate } = childContext; + return getMockInstance(authenticate) as jest.Mock; +} + +function setAuthUser(fastify: FastifyInstance, userEmail: string) { + // eslint-disable-next-line @typescript-eslint/require-await + getMockAuthenticateFromServer(fastify).mockImplementation(async (request) => { + (request as unknown as { user: { sub: string } }).user = { sub: userEmail }; + }); +} + +function unsetAuthUser(fastify: FastifyInstance) { + getMockAuthenticateFromServer(fastify).mockImplementation(async (_request, reply) => { + await reply.code(HTTP_STATUS_CODES.UNAUTHORIZED).send(); + }); +} + +type RouteLevel = 'ORG_BULK' | 'ORG_MEMBERSHIP_RESTRICTED' | 'ORG_MEMBERSHIP' | 'ORG'; + +interface Processor { + readonly spy: jest.Mock<() => Promise>>; + readonly result?: ProcessorResolvedValue; +} + +const REQUIRED_API_ENV_VARS = { ...REQUIRED_ENV_VARS, OAUTH2_JWKS_URL, OAUTH2_TOKEN_AUDIENCE, OAUTH2_TOKEN_ISSUER, }; -export function makeTestApiServer(logger?: BaseLogger): () => FastifyTypedInstance { - configureMockEnvVars(REQUIRED_API_ENV_VARS); - return makeTestServer(makeApiServer, logger); +export function makeTestApiServer(): () => TestServerFixture { + const getFixture = makeTestServer(makeApiServer, REQUIRED_API_ENV_VARS); + + beforeEach(() => { + const { envVarMocker, server } = getFixture(); + + setAuthUser(server, SUPER_ADMIN_EMAIL); + + envVarMocker({ ...REQUIRED_API_ENV_VARS, AUTHORITY_SUPERADMIN: SUPER_ADMIN_EMAIL }); + }); + + return getFixture; +} + +export function testOrgRouteAuth( + routeLevel: RouteLevel, + requestOptions: InjectOptions, + fixtureGetter: () => TestServerFixture, + processor: Processor, +): void { + let server: FastifyInstance; + beforeEach(() => { + ({ server } = fixtureGetter()); + }); + + beforeEach(() => { + const result = { + didSucceed: true, + result: processor.result, + }; + processor.spy.mockResolvedValue(result as SuccessfulResult); + }); + + async function createOrgMember(member: MemberModelSchema, id?: string): Promise { + const { dbConnection } = fixtureGetter(); + const memberModel = getModelForClass(MemberModelSchema, { + existingConnection: dbConnection, + }); + // eslint-disable-next-line @typescript-eslint/naming-convention + await memberModel.create({ ...member, _id: id }); + } + + function expectAccessToBeGranted( + response: LightMyRequestResponse, + reason: string, + expectedUserEmail: string = MEMBER_EMAIL, + ) { + expect(response.statusCode).toBeGreaterThanOrEqual(HTTP_STATUS_CODES.OK); + expect(response.statusCode).toBeLessThan(HTTP_STATUS_CODES.BAD_REQUEST); + expect(processor.spy).toHaveBeenCalled(); + expect(fixtureGetter().logs).toContainEqual( + partialPinoLog('debug', 'Authorisation granted', { userEmail: expectedUserEmail, reason }), + ); + } + + function expectAccessToBeDenied( + response: LightMyRequestResponse, + reason: string, + expectedUserEmail: string = MEMBER_EMAIL, + ) { + expect(response.statusCode).toBe(HTTP_STATUS_CODES.FORBIDDEN); + expect(processor.spy).not.toHaveBeenCalled(); + expect(fixtureGetter().logs).toContainEqual( + partialPinoLog('info', 'Authorisation denied', { userEmail: expectedUserEmail, reason }), + ); + } + + test('Anonymous access should be denied', async () => { + unsetAuthUser(server); + + const response = await server.inject(requestOptions); + + expect(response.statusCode).toBe(HTTP_STATUS_CODES.UNAUTHORIZED); + expect(processor.spy).not.toHaveBeenCalled(); + }); + + test('Super admin should be granted access', async () => { + setAuthUser(server, SUPER_ADMIN_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeGranted(response, 'User is super admin', SUPER_ADMIN_EMAIL); + }); + + if (routeLevel === 'ORG_BULK') { + test('Any org admin should be denied access', async () => { + await createOrgMember(ORG_ADMIN); + setAuthUser(server, ORG_ADMIN_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeDenied( + response, + 'Non-super admin tries to access bulk org endpoint', + ORG_ADMIN_EMAIL, + ); + }); + } else { + test('Org admin should be granted access', async () => { + await createOrgMember(ORG_ADMIN); + setAuthUser(server, ORG_ADMIN_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeGranted(response, 'User is org admin', ORG_ADMIN_EMAIL); + }); + } + + if (routeLevel === 'ORG') { + test('Admin from different org should be denied access', async () => { + await createOrgMember({ + ...ORG_ADMIN, + orgName: `not-${ORG_MEMBER.orgName}`, + }); + setAuthUser(server, ORG_ADMIN_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeDenied(response, 'User is not a member of the org', ORG_ADMIN_EMAIL); + }); + } + + if (routeLevel === 'ORG_MEMBERSHIP') { + test('Org member should be granted access', async () => { + await createOrgMember(ORG_MEMBER, MEMBER_MONGO_ID); + setAuthUser(server, MEMBER_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeGranted(response, 'User is accessing their own membership'); + }); + + test('Another member from same org should be denied access', async () => { + await createOrgMember({ ...ORG_MEMBER, email: `not-${MEMBER_EMAIL}` }); + setAuthUser(server, MEMBER_EMAIL); + + const response = await server.inject(requestOptions); + + expectAccessToBeDenied(response, 'User is not a member of the org'); + }); + } else { + test('Org member should be denied access', async () => { + await createOrgMember(ORG_MEMBER, MEMBER_MONGO_ID); + setAuthUser(server, MEMBER_EMAIL); + + const response = await server.inject(requestOptions); + + let reason: string; + switch (routeLevel) { + case 'ORG_MEMBERSHIP_RESTRICTED': { + reason = 'User is not an admin'; + break; + } + case 'ORG_BULK': { + reason = 'Non-super admin tries to access bulk org endpoint'; + break; + } + default: { + reason = 'User is not accessing their membership'; + break; + } + } + expectAccessToBeDenied(response, reason); + }); + } } diff --git a/src/testUtils/db.ts b/src/testUtils/db.ts index a8ef81c4..242482be 100644 --- a/src/testUtils/db.ts +++ b/src/testUtils/db.ts @@ -1,4 +1,7 @@ /* eslint-disable require-atomic-updates */ + +import { randomUUID } from 'node:crypto'; + import { createConnection, type Connection, type ConnectOptions } from 'mongoose'; import { deleteModelWithClass } from '@typegoose/typegoose'; @@ -15,7 +18,10 @@ const MODEL_SCHEMAS = Object.values([ ]).filter((schema) => typeof schema === 'function'); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,no-underscore-dangle -export const MONGODB_URI = (global as any).__MONGO_URI__ as string; +const BASE_MONGO_URI = (global as any).__MONGO_URI__ as string; + +// Ensure every Jest worker gets its own database. +export const MONGODB_URI = `${BASE_MONGO_URI}${randomUUID()}`; export function setUpTestDbConnection(): () => Connection { let connection: Connection; diff --git a/src/testUtils/envVars.ts b/src/testUtils/envVars.ts index c3d38434..9673ee77 100644 --- a/src/testUtils/envVars.ts +++ b/src/testUtils/envVars.ts @@ -4,7 +4,7 @@ import envVar from 'env-var'; import { K_SINK } from './eventing/stubs.js'; import { MONGODB_URI } from './db.js'; -interface EnvVarSet { +export interface EnvVarSet { readonly [key: string]: string | undefined; } @@ -14,7 +14,9 @@ export const REQUIRED_ENV_VARS = { MONGODB_URI, }; -export function configureMockEnvVars(envVars: EnvVarSet = {}): (envVars: EnvVarSet) => void { +export type EnvVarMocker = (envVars: EnvVarSet) => void; + +export function configureMockEnvVars(envVars: EnvVarSet = {}): EnvVarMocker { const mockEnvVarGet = jest.spyOn(envVar, 'get'); function setEnvironmentVariables(newEnvVars: EnvVarSet): void { mockEnvVarGet.mockReset(); diff --git a/src/testUtils/logging.ts b/src/testUtils/logging.ts index ea0c074d..db995473 100644 --- a/src/testUtils/logging.ts +++ b/src/testUtils/logging.ts @@ -8,6 +8,8 @@ interface MockLogging { readonly logs: MockLogSet; } +export type { MockLogSet }; + export function makeMockLogging(): MockLogging { const logs: any[] = []; const stream = split2((data) => { diff --git a/src/testUtils/queueServer.ts b/src/testUtils/queueServer.ts index a0aaac79..1323e278 100644 --- a/src/testUtils/queueServer.ts +++ b/src/testUtils/queueServer.ts @@ -1,12 +1,10 @@ -import type { BaseLogger } from 'pino'; - -import type { FastifyTypedInstance } from '../utilities/fastify/FastifyTypedInstance.js'; import { makeQueueServer } from '../backgroundQueue/server.js'; -import { makeTestServer } from './server.js'; +import { makeTestServer, type TestServerFixture } from './server.js'; +import { REQUIRED_ENV_VARS } from './envVars.js'; -export { REQUIRED_ENV_VARS as REQUIRED_QUEUE_ENV_VARS } from './envVars.js'; +const REQUIRED_QUEUE_ENV_VARS = REQUIRED_ENV_VARS; -export function setUpTestQueueServer(logger?: BaseLogger): () => FastifyTypedInstance { - return makeTestServer(makeQueueServer, logger); +export function setUpTestQueueServer(): () => TestServerFixture { + return makeTestServer(makeQueueServer, REQUIRED_QUEUE_ENV_VARS); } diff --git a/src/testUtils/server.ts b/src/testUtils/server.ts index dd2465fe..90f4e0ef 100644 --- a/src/testUtils/server.ts +++ b/src/testUtils/server.ts @@ -1,17 +1,35 @@ -import type { BaseLogger } from 'pino'; +import type { FastifyInstance } from 'fastify'; +import type { Connection } from 'mongoose'; -import type { FastifyTypedInstance } from '../utilities/fastify/FastifyTypedInstance.js'; import type { ServerMaker } from '../utilities/fastify/ServerMaker.js'; -export function makeTestServer(serverMaker: ServerMaker, logger?: BaseLogger) { - let server: FastifyTypedInstance; +import { makeMockLogging, type MockLogSet } from './logging.js'; +import { configureMockEnvVars, type EnvVarSet, type EnvVarMocker } from './envVars.js'; +import { setUpTestDbConnection } from './db.js'; + +export interface TestServerFixture { + readonly server: FastifyInstance; + readonly dbConnection: Connection; + readonly logs: MockLogSet; + readonly envVarMocker: EnvVarMocker; +} + +export function makeTestServer( + serverMaker: ServerMaker, + envVars: EnvVarSet, +): () => TestServerFixture { + const envVarMocker = configureMockEnvVars(envVars); + const mockLogging = makeMockLogging(); + const getConnection = setUpTestDbConnection(); + + let server: FastifyInstance; beforeEach(async () => { - server = await serverMaker(logger); + server = await serverMaker(mockLogging.logger); }); afterEach(async () => { await server.close(); }); - return () => server; + return () => ({ server, dbConnection: getConnection(), logs: mockLogging.logs, envVarMocker }); } diff --git a/src/testUtils/stubs.ts b/src/testUtils/stubs.ts index 364f7167..af46e556 100644 --- a/src/testUtils/stubs.ts +++ b/src/testUtils/stubs.ts @@ -4,7 +4,7 @@ export const AWALA_ENDPOINT = 'awala-example.com'; export const NON_ASCII_AWALA_ENDPOINT = 'はじめよう.みんな'; export const MEMBER_NAME = 'TEST_MEMBER_NAME'; export const NON_ASCII_MEMBER_NAME = 'テスト ユーザー'; -export const MEMBER_EMAIL = 'membet@example.com'; +export const MEMBER_EMAIL = 'member@example.com'; export const MEMBER_MONGO_ID = '6424ad273f75645b35f9ee79'; export const TEST_SERVICE_OID = '1.2.250.1'; export const MEMBER_PUBLIC_KEY_MONGO_ID = '7555ad273f75645b35f95555'; diff --git a/src/utilities/fastify/plugins/jwksAuthentication.spec.ts b/src/utilities/fastify/plugins/jwksAuthentication.spec.ts new file mode 100644 index 00000000..b5f0ce4e --- /dev/null +++ b/src/utilities/fastify/plugins/jwksAuthentication.spec.ts @@ -0,0 +1,153 @@ +import { jest } from '@jest/globals'; +import type { FastifyInstance } from 'fastify'; +import envVar from 'env-var'; +import fastifyOauth2Verify from 'fastify-auth0-verify'; + +import { mockSpy } from '../../../testUtils/jest.js'; +import { + OAUTH2_JWKS_URL, + OAUTH2_TOKEN_AUDIENCE, + OAUTH2_TOKEN_ISSUER, +} from '../../../testUtils/authn.js'; +import { configureMockEnvVars } from '../../../testUtils/envVars.js'; + +import jwksPlugin from './jwksAuthentication.js'; + +const AUTHN_ENV_VARS = { + OAUTH2_JWKS_URL, + OAUTH2_TOKEN_AUDIENCE, + OAUTH2_TOKEN_ISSUER, +}; + +const mockEnvVars = configureMockEnvVars(AUTHN_ENV_VARS); + +describe('jwks-authentication', () => { + const mockRegister = mockSpy(jest.fn()); + const mockFastify: FastifyInstance = { register: mockRegister } as any; + + test('OAUTH2_JWKS_URL should be defined', async () => { + mockEnvVars({ ...AUTHN_ENV_VARS, OAUTH2_JWKS_URL: undefined }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + envVar.EnvVarError, + /OAUTH2_JWKS_URL/u, + ); + }); + + test('OAUTH2_JWKS_URL should be used as the domain', async () => { + await jwksPlugin(mockFastify, {}); + + expect(mockFastify.register).toHaveBeenCalledWith( + fastifyOauth2Verify, + expect.objectContaining({ domain: OAUTH2_JWKS_URL }), + ); + }); + + test('OAUTH2_JWKS_URL should be a well-formed URL', async () => { + const malformedUrl = 'not a url'; + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_JWKS_URL: malformedUrl, + }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + envVar.EnvVarError, + /OAUTH2_JWKS_URL/u, + ); + }); + + test('OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', async () => { + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_TOKEN_ISSUER: undefined, + OAUTH2_TOKEN_ISSUER_REGEX: undefined, + }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + Error, + 'Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', + ); + }); + + test('Both OAUTH2_TOKEN_ISSUER and OAUTH2_TOKEN_ISSUER_REGEX should not be set', async () => { + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_TOKEN_ISSUER_REGEX: OAUTH2_TOKEN_ISSUER, + }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + Error, + 'Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set', + ); + }); + + test('OAUTH2_TOKEN_ISSUER should be used as the issuer if set', async () => { + await jwksPlugin(mockFastify, {}); + + expect(mockFastify.register).toHaveBeenCalledWith( + fastifyOauth2Verify, + expect.objectContaining({ issuer: OAUTH2_TOKEN_ISSUER }), + ); + }); + + test('OAUTH2_TOKEN_ISSUER should be a well-formed URL', async () => { + const malformedUrl = 'not a url'; + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_TOKEN_ISSUER: malformedUrl, + }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + envVar.EnvVarError, + /OAUTH2_TOKEN_ISSUER/u, + ); + }); + + test('OAUTH2_TOKEN_ISSUER_REGEX should be used as the issuer if set', async () => { + const issuerRegex = '^this is a regex$'; + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_TOKEN_ISSUER: undefined, + OAUTH2_TOKEN_ISSUER_REGEX: issuerRegex, + }); + + await jwksPlugin(mockFastify, {}); + + expect(mockFastify.register).toHaveBeenCalledWith( + fastifyOauth2Verify, + expect.objectContaining({ issuer: new RegExp(issuerRegex, 'u') }), + ); + }); + + test('OAUTH2_TOKEN_ISSUER_REGEX should be a well-formed regex', async () => { + const malformedRegex = '['; + mockEnvVars({ + ...AUTHN_ENV_VARS, + OAUTH2_TOKEN_ISSUER: undefined, + OAUTH2_TOKEN_ISSUER_REGEX: malformedRegex, + }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + envVar.EnvVarError, + /OAUTH2_TOKEN_ISSUER_REGEX/u, + ); + }); + + test('OAUTH2_TOKEN_AUDIENCE should be defined', async () => { + mockEnvVars({ ...AUTHN_ENV_VARS, OAUTH2_TOKEN_AUDIENCE: undefined }); + + await expect(jwksPlugin(mockFastify, {})).rejects.toThrowWithMessage( + envVar.EnvVarError, + /OAUTH2_TOKEN_AUDIENCE/u, + ); + }); + + test('OAUTH2_TOKEN_AUDIENCE should be used as the audience', async () => { + await jwksPlugin(mockFastify, {}); + + expect(mockFastify.register).toHaveBeenCalledWith( + fastifyOauth2Verify, + expect.objectContaining({ audience: OAUTH2_TOKEN_AUDIENCE }), + ); + }); +}); diff --git a/src/utilities/fastify/plugins/jwksAuthentication.ts b/src/utilities/fastify/plugins/jwksAuthentication.ts new file mode 100644 index 00000000..4c68c10e --- /dev/null +++ b/src/utilities/fastify/plugins/jwksAuthentication.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; +import fastifyAuth0Verify, { type FastifyAuth0VerifyOptions } from 'fastify-auth0-verify'; +import env from 'env-var'; + +function getOauth2PluginOptions(): FastifyAuth0VerifyOptions { + const audience = env.get('OAUTH2_TOKEN_AUDIENCE').required().asString(); + const domain = env.get('OAUTH2_JWKS_URL').required().asUrlString(); + + const issuer = env.get('OAUTH2_TOKEN_ISSUER').asUrlString(); + const issuerRegex = env.get('OAUTH2_TOKEN_ISSUER_REGEX').asRegExp('u'); + if ( + (issuer === undefined && issuerRegex === undefined) || + (issuer !== undefined && issuerRegex !== undefined) + ) { + throw new Error('Either OAUTH2_TOKEN_ISSUER or OAUTH2_TOKEN_ISSUER_REGEX should be set'); + } + + return { + audience, + domain, + issuer: issuer ?? (issuerRegex as any), + }; +} + +async function registerJwksPlugin(fastify: FastifyInstance): Promise { + const options = getOauth2PluginOptions(); + await fastify.register(fastifyAuth0Verify, options); +} + +const jwksPlugin = fastifyPlugin(registerJwksPlugin, { + name: 'jwks-authentication', +}); +export default jwksPlugin; diff --git a/src/utilities/fastify/plugins/notFoundHandler.spec.ts b/src/utilities/fastify/plugins/notFoundHandler.spec.ts index ccec6daf..1fb928f2 100644 --- a/src/utilities/fastify/plugins/notFoundHandler.spec.ts +++ b/src/utilities/fastify/plugins/notFoundHandler.spec.ts @@ -1,31 +1,46 @@ import type { HTTPMethods } from 'fastify'; -import { configureMockEnvVars } from '../../../testUtils/envVars.js'; +import { REQUIRED_ENV_VARS } from '../../../testUtils/envVars.js'; import { HTTP_STATUS_CODES } from '../../http.js'; import type { FastifyTypedInstance } from '../FastifyTypedInstance.js'; -import { HTTP_METHODS } from '../server.js'; -import { REQUIRED_API_ENV_VARS, makeTestApiServer } from '../../../testUtils/apiServer.js'; +import { HTTP_METHODS, makeFastify } from '../server.js'; +import { makeTestServer } from '../../../testUtils/server.js'; describe('notFoundHandler', () => { - configureMockEnvVars(REQUIRED_API_ENV_VARS); - const getTestServer = makeTestApiServer(); + const endpointUrl = '/'; + const allowedMethods: HTTPMethods[] = ['HEAD', 'GET']; + + const getTestServerFixture = makeTestServer( + async () => + makeFastify((fastify, _opts, done) => { + fastify.route({ + method: allowedMethods, + url: endpointUrl, + + async handler(_req, reply) { + await reply.code(HTTP_STATUS_CODES.OK).send(); + }, + }); + + done(); + }), + REQUIRED_ENV_VARS, + ); + let serverInstance: FastifyTypedInstance; beforeEach(() => { - serverInstance = getTestServer(); + serverInstance = getTestServerFixture().server; }); - const allowedMethods: HTTPMethods[] = ['HEAD', 'GET']; const allowedMethodsString = allowedMethods.join(', '); const disallowedMethods = HTTP_METHODS.filter( (method) => !allowedMethods.includes(method) && method !== 'OPTIONS', ); - const endpointUrl = '/'; test('An existing method should be routed to the handler', async () => { const response = await serverInstance.inject({ method: 'GET', url: endpointUrl }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.OK); - expect(response).toHaveProperty('headers.content-type', 'text/plain'); }); test.each(disallowedMethods)('%s requests should be refused', async (method) => { diff --git a/src/utilities/fastify/server.spec.ts b/src/utilities/fastify/server.spec.ts index 518b31bc..4a85c5e0 100644 --- a/src/utilities/fastify/server.spec.ts +++ b/src/utilities/fastify/server.spec.ts @@ -6,6 +6,7 @@ import { configureMockEnvVars } from '../../testUtils/envVars.js'; import { getMockContext, getMockInstance, mockSpy } from '../../testUtils/jest.js'; import fastifyMongoose from './plugins/fastifyMongoose.js'; +import notFoundHandler from './plugins/notFoundHandler.js'; const mockListen = mockSpy(jest.fn<() => Promise>()); const mockRegister = mockSpy(jest.fn()); @@ -99,11 +100,17 @@ describe('makeFastify', () => { expect(fastifyCallArguments).toHaveProperty('trustProxy', true); }); - test('The fastifyMongoose plugin should be configured', async () => { + test('fastifyMongoose plugin should be configured', async () => { await makeFastify(mockPlugin); expect(mockFastify.register).toHaveBeenCalledWith(fastifyMongoose); }); + + test('notFoundHandler plugin should be configured', async () => { + await makeFastify(mockPlugin); + + expect(mockFastify.register).toHaveBeenCalledWith(notFoundHandler); + }); }); describe('runFastify', () => { diff --git a/src/utilities/fastify/server.ts b/src/utilities/fastify/server.ts index 36bff989..a88a80d8 100644 --- a/src/utilities/fastify/server.ts +++ b/src/utilities/fastify/server.ts @@ -1,3 +1,4 @@ +import fastifyRoutes from '@fastify/routes'; import { fastify, type FastifyInstance, @@ -12,6 +13,7 @@ import { makeLogger } from '../logging.js'; import { configureExitHandling } from '../exitHandling.js'; import fastifyMongoose from './plugins/fastifyMongoose.js'; +import notFoundHandler from './plugins/notFoundHandler.js'; const SERVER_PORT = 8080; const SERVER_HOST = '0.0.0.0'; @@ -49,6 +51,9 @@ export async function makeFastify( await server.register(fastifyMongoose); + await server.register(fastifyRoutes); + await server.register(notFoundHandler); + await server.register(appPlugin); await server.ready(); diff --git a/src/utilities/http.ts b/src/utilities/http.ts index 08c889de..4b382e08 100644 --- a/src/utilities/http.ts +++ b/src/utilities/http.ts @@ -3,6 +3,8 @@ export const HTTP_STATUS_CODES = { ACCEPTED: 202, NO_CONTENT: 204, BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, NOT_FOUND: 404, METHOD_NOT_ALLOWED: 405, CONFLICT: 409,