diff --git a/test/forms.integration.spec.ts b/test/forms.integration.spec.ts index f930016..986ae36 100644 --- a/test/forms.integration.spec.ts +++ b/test/forms.integration.spec.ts @@ -9,7 +9,9 @@ import { vi, } from 'vitest'; import request from 'supertest'; -import { randomUUID } from 'node:crypto'; +import { generateKeyPairSync, randomUUID, type KeyObject } from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import nock from 'nock'; import letsTalkDefinition from '../examples/lets-talk.json'; import { createApp } from '../src/bootstrap'; import { DbService } from '../src/db.service'; @@ -33,10 +35,48 @@ describe('forms API with real PostgreSQL', () => { let reporter: string; let member: string; let machine: string; + let machineKey: KeyObject; + const machineKeyId = 'forms-integration-machine'; + + /** + * Signs audience-bound machine credentials for the shared RSA verification policy. + * @param scope Space-separated forms permissions. @returns Signed machine bearer token. + * @throws JWT signing errors for invalid fixture keys or claims. + */ + function machineToken(scope: string) { + return jwt.sign( + { gty: 'client-credentials', azp: 'test-client', scope }, + machineKey, + { + algorithm: 'RS256', + keyid: machineKeyId, + subject: 'test-client@clients', + issuer: 'https://forms.test', + audience: 'forms-api', + expiresIn: '5m', + }, + ); + } beforeAll(async () => { + const keys = generateKeyPairSync('rsa', { modulusLength: 2048 }); + machineKey = keys.privateKey; + // Mock key discovery only; the shared authenticator still verifies RSA signatures. + nock('https://forms.test') + .get('/.well-known/jwks.json') + .reply(200, { + keys: [ + { + ...keys.publicKey.export({ format: 'jwk' }), + kid: machineKeyId, + alg: 'RS256', + use: 'sig', + }, + ], + }); app = await createApp({ ...testConfig(), + machineAudience: 'forms-api', busApi: { BUSAPI_URL: 'https://bus.test/v6', AUTH0_URL: 'https://auth.test', @@ -54,17 +94,14 @@ describe('forms API with real PostgreSQL', () => { 'https://topcoder.com/roles': ['Member'], 'https://topcoder.com/userId': '12345', }); - machine = await token({ - gty: 'client-credentials', - azp: 'test-client', - scope: 'manage:forms read:forms-submissions', - }); + machine = machineToken('manage:forms read:forms-submissions'); }); beforeEach(() => { postEvent.mockReset().mockResolvedValue(undefined); }); afterAll(async () => { if (app) await app.close(); + nock.cleanAll(); }); /** @@ -186,11 +223,7 @@ describe('forms API with real PostgreSQL', () => { key, title: 'Event interest', }); - const readOnly = await token({ - gty: 'client-credentials', - azp: 'test-client', - scope: 'read:forms-submissions', - }); + const readOnly = machineToken('read:forms-submissions'); await request(app.getHttpServer()) .get('/v6/forms/reports/directory') .auth(readOnly, { type: 'bearer' }) diff --git a/test/policies.spec.ts b/test/policies.spec.ts index 48fd77c..792f162 100644 --- a/test/policies.spec.ts +++ b/test/policies.spec.ts @@ -127,17 +127,26 @@ describe('definition and deployment policies', () => { machine: false, roles: ['member'], }); + // Machine identity is decided by the shared tc-core-library-js verifier (`isMachine`); + // a machine never gets a member identity, even with a userId claim. expect( normalizeActor( { sub: 'client@clients', userId: 'forged', - roles: ['Administrator'], gty: 'client-credentials', + isMachine: true, }, 'https://topcoder.com/', ), ).toMatchObject({ memberId: undefined, machine: true }); + // Without the verifier's flag, machine-looking claims are not trusted as a machine. + expect( + normalizeActor( + { sub: 'client@clients', gty: 'client-credentials' }, + 'https://topcoder.com/', + ), + ).toMatchObject({ machine: false }); }); it('requires signed-token settings and exact CORS origins, defaulting to untrusted proxy headers', () => {