From 59fc3cdebf14665136e81958e654cd13a4a35573 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 23 Apr 2026 21:37:30 -0500 Subject: [PATCH] fix(federation): use real PEM certs in enrollment + ca service tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #501 (FED-M2-11) added strict `new X509Certificate(certPem)` parsing without falling back silently — but the existing test mocks were still returning dummy strings, so 13+6 tests have been failing on `main` since that merge with `error:0680007B:asn1 encoding routines::header too long`. Also fixes a mock gap introduced by CRIT-2 in the same PR: the tx mock did not have a `.returning()` method on the grant-activation update path, causing 6 of the 13 failures with `TypeError: not a function`. Add a test helper that generates a real self-signed EC P-256 cert via @peculiar/x509 and use it in the EnrollmentService and CaService spec mocks. Two variants: - makeSelfSignedCert() plain cert for extractCertNotAfter - makeMosaicIssuedCert(opts) cert with Mosaic OID extensions for CRIT-1 Production strictness is intentionally preserved. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/enrollment.service.spec.ts | 72 ++++++--- .../federation/__tests__/helpers/test-cert.ts | 138 ++++++++++++++++++ .../gateway/src/federation/ca.service.spec.ts | 45 ++++-- 3 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 apps/gateway/src/federation/__tests__/helpers/test-cert.ts diff --git a/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts b/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts index 8bf474e..558f6da 100644 --- a/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts +++ b/apps/gateway/src/federation/__tests__/enrollment.service.spec.ts @@ -24,10 +24,11 @@ */ import 'reflect-metadata'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; import { GoneException, NotFoundException } from '@nestjs/common'; import type { Db } from '@mosaicstack/db'; import { EnrollmentService } from '../enrollment.service.js'; +import { makeSelfSignedCert } from './helpers/test-cert.js'; // --------------------------------------------------------------------------- // Test constants @@ -38,10 +39,18 @@ const PEER_ID = 'p2222222-2222-2222-2222-222222222222'; const USER_ID = 'u3333333-3333-3333-3333-333333333333'; const TOKEN = 'a'.repeat(64); // 64-char hex -const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n'; -const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM; +// Real self-signed EC P-256 cert — populated once in beforeAll. +// Required because EnrollmentService.extractCertNotAfter calls new X509Certificate(certPem) +// with strict parsing (PR #501 HIGH-2: no silent fallback). +let REAL_CERT_PEM: string; + +const MOCK_CHAIN_PEM = () => REAL_CERT_PEM + REAL_CERT_PEM; const MOCK_SERIAL = 'ABCD1234'; +beforeAll(async () => { + REAL_CERT_PEM = await makeSelfSignedCert(); +}); + // --------------------------------------------------------------------------- // Factory helpers // --------------------------------------------------------------------------- @@ -103,11 +112,27 @@ function makeDb({ const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock }); // transaction(cb) — cb receives txMock; txMock has update + insert - const txInsertValues = vi.fn().mockResolvedValue(undefined); - const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues }); - const txWhereUpdate = vi.fn().mockResolvedValue(undefined); + // + // The tx mock must support two tx.update() call patterns (CRIT-2, PR #501): + // 1. Grant activation: .update().set().where().returning() → resolves to [{ id }] + // 2. Peer update: .update().set().where() → resolves to undefined + // + // We achieve this by making txWhereUpdate return an object with BOTH a thenable + // interface (so `await tx.update().set().where()` works) AND a .returning() method. + const txGrantActivatedRow = { id: GRANT_ID }; + const txReturningMock = vi.fn().mockResolvedValue([txGrantActivatedRow]); + const txWhereUpdate = vi.fn().mockReturnValue({ + // .returning() for grant activation (first tx.update call) + returning: txReturningMock, + // thenables so `await tx.update().set().where()` also works for peer update + then: (resolve: (v: undefined) => void) => resolve(undefined), + catch: () => undefined, + finally: () => undefined, + }); const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate }); const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock }); + const txInsertValues = vi.fn().mockResolvedValue(undefined); + const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues }); const txMock = { update: txUpdateMock, insert: txInsertMock }; const transactionMock = vi .fn() @@ -132,6 +157,7 @@ function makeDb({ txInsertValues, txInsertMock, txWhereUpdate, + txReturningMock, txSetMock, txUpdateMock, txMock, @@ -146,11 +172,13 @@ function makeDb({ function makeCaService() { return { - issueCert: vi.fn().mockResolvedValue({ - certPem: MOCK_CERT_PEM, - certChainPem: MOCK_CHAIN_PEM, + // REAL_CERT_PEM is populated by beforeAll — safe to reference via closure here + // because makeCaService() is only called after the suite's beforeAll runs. + issueCert: vi.fn().mockImplementation(async () => ({ + certPem: REAL_CERT_PEM, + certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL, - }), + })), }; } @@ -301,29 +329,29 @@ describe('EnrollmentService.redeem — success path', () => { }); caService.issueCert.mockImplementation(async () => { callOrder.push('issueCert'); - return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL }; + return { certPem: REAL_CERT_PEM, certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL }; }); - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(callOrder).toEqual(['claim', 'issueCert']); }); it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => { - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(caService.issueCert).toHaveBeenCalledWith( expect.objectContaining({ grantId: GRANT_ID, subjectUserId: USER_ID, - csrPem: MOCK_CERT_PEM, + csrPem: '---CSR---', ttlSeconds: 300, }), ); }); it('runs activate grant + peer update + audit inside a transaction', async () => { - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(db._mocks.transactionMock).toHaveBeenCalledOnce(); // tx.update called twice: activate grant + update peer @@ -333,17 +361,17 @@ describe('EnrollmentService.redeem — success path', () => { }); it('activates grant (sets status=active) inside the transaction', async () => { - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); }); it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => { - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(db._mocks.txSetMock).toHaveBeenCalledWith( expect.objectContaining({ - certPem: MOCK_CERT_PEM, + certPem: REAL_CERT_PEM, certSerial: MOCK_SERIAL, state: 'active', }), @@ -351,7 +379,7 @@ describe('EnrollmentService.redeem — success path', () => { }); it('inserts an audit log row inside the transaction', async () => { - await service.redeem(TOKEN, MOCK_CERT_PEM); + await service.redeem(TOKEN, '---CSR---'); expect(db._mocks.txInsertValues).toHaveBeenCalledWith( expect.objectContaining({ @@ -363,11 +391,11 @@ describe('EnrollmentService.redeem — success path', () => { }); it('returns { certPem, certChainPem } from CaService', async () => { - const result = await service.redeem(TOKEN, MOCK_CERT_PEM); + const result = await service.redeem(TOKEN, '---CSR---'); expect(result).toEqual({ - certPem: MOCK_CERT_PEM, - certChainPem: MOCK_CHAIN_PEM, + certPem: REAL_CERT_PEM, + certChainPem: MOCK_CHAIN_PEM(), }); }); }); diff --git a/apps/gateway/src/federation/__tests__/helpers/test-cert.ts b/apps/gateway/src/federation/__tests__/helpers/test-cert.ts new file mode 100644 index 0000000..7a1b6bc --- /dev/null +++ b/apps/gateway/src/federation/__tests__/helpers/test-cert.ts @@ -0,0 +1,138 @@ +/** + * Test helpers for generating real X.509 PEM certificates in unit tests. + * + * PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing + * in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy + * cert strings now throw `error:0680007B:asn1 encoding routines::header too long`. + * + * These helpers produce minimal but cryptographically valid self-signed EC P-256 + * certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks. + * + * Two variants: + * - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse. + * - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the + * CRIT-1 OID presence + value checks in CaService.issueCert. + */ + +import { webcrypto } from 'node:crypto'; +import { + X509CertificateGenerator, + Extension, + KeyUsagesExtension, + KeyUsageFlags, + BasicConstraintsExtension, + cryptoProvider, +} from '@peculiar/x509'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Encode a string as an ASN.1 UTF8String TLV: + * 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes. + * + * CaService.issueCert reads the extension value as: + * decoder.decode(grantIdExt.value.slice(2)) + * i.e. it skips the tag + length byte and decodes the remainder as UTF-8. + * So we must produce exactly this encoding as the OCTET STRING content. + */ +function encodeUtf8String(value: string): Uint8Array { + const utf8 = new TextEncoder().encode(value); + if (utf8.length > 127) { + throw new Error('encodeUtf8String: value too long for single-byte length encoding'); + } + const buf = new Uint8Array(2 + utf8.length); + buf[0] = 0x0c; // ASN.1 UTF8String tag + buf[1] = utf8.length; + buf.set(utf8, 2); + return buf; +} + +// --------------------------------------------------------------------------- +// Mosaic OID constants (must match production CaService) +// --------------------------------------------------------------------------- + +const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1'; +const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate a minimal self-signed EC P-256 certificate valid for 1 day. + * CN=harness-test, no custom extensions. + * + * Suitable for: + * - EnrollmentService.extractCertNotAfter (just needs parseable PEM) + * - Any mock that returns certPem / certChainPem without OID checks + */ +export async function makeSelfSignedCert(): Promise { + // Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+, + // but we set it explicitly here to be safe on all Node 18+ versions). + cryptoProvider.set(webcrypto as unknown as Parameters[0]); + + const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; + const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); + + const now = new Date(); + const tomorrow = new Date(now.getTime() + 86_400_000); + + const cert = await X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: 'CN=harness-test', + notBefore: now, + notAfter: tomorrow, + signingAlgorithm: alg, + keys, + extensions: [ + new BasicConstraintsExtension(false), + new KeyUsagesExtension(KeyUsageFlags.digitalSignature), + ], + }); + + return cert.toString('pem'); +} + +/** + * Generate a self-signed EC P-256 certificate that contains the two custom + * Mosaic OID extensions required by CaService.issueCert's CRIT-1 check: + * OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId) + * OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId) + * + * The extension value encoding matches the production parser's `.slice(2)` assumption: + * each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV. + */ +export async function makeMosaicIssuedCert(opts: { + grantId: string; + subjectUserId: string; +}): Promise { + // Ensure @peculiar/x509 uses Node.js webcrypto. + cryptoProvider.set(webcrypto as unknown as Parameters[0]); + + const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; + const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); + + const now = new Date(); + const tomorrow = new Date(now.getTime() + 86_400_000); + + const cert = await X509CertificateGenerator.createSelfSigned({ + serialNumber: '01', + name: 'CN=mosaic-issued-test', + notBefore: now, + notAfter: tomorrow, + signingAlgorithm: alg, + keys, + extensions: [ + new BasicConstraintsExtension(false), + new KeyUsagesExtension(KeyUsageFlags.digitalSignature), + // mosaic_grant_id — OID 1.3.6.1.4.1.99999.1 + new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)), + // mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2 + new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)), + ], + }); + + return cert.toString('pem'); +} diff --git a/apps/gateway/src/federation/ca.service.spec.ts b/apps/gateway/src/federation/ca.service.spec.ts index 11dc5f8..0fc64a7 100644 --- a/apps/gateway/src/federation/ca.service.spec.ts +++ b/apps/gateway/src/federation/ca.service.spec.ts @@ -20,9 +20,10 @@ */ import 'reflect-metadata'; -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, beforeAll, type Mock } from 'vitest'; import { jwtVerify, exportJWK, generateKeyPair } from 'jose'; import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509'; +import { makeMosaicIssuedCert } from './__tests__/helpers/test-cert.js'; // --------------------------------------------------------------------------- // Mock node:https BEFORE importing CaService so the mock is in place when @@ -74,6 +75,11 @@ const FAKE_CA_PEM = FAKE_CERT_PEM; const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22'; +// Real self-signed cert containing both Mosaic OID extensions — populated in beforeAll. +// Required because CaService.issueCert performs CRIT-1 OID presence/value checks on the +// response cert (PR #501 — strict parsing, no silent fallback). +let realIssuedCertPem: string; + // --------------------------------------------------------------------------- // Generate a real EC P-256 key pair and CSR for integration-style tests // --------------------------------------------------------------------------- @@ -194,6 +200,15 @@ function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): vo describe('CaService', () => { let service: CaService; + beforeAll(async () => { + // Generate a cert with the two Mosaic OIDs so that CaService.issueCert's + // CRIT-1 OID checks pass when mock step-ca returns it as `crt`. + realIssuedCertPem = await makeMosaicIssuedCert({ + grantId: GRANT_ID, + subjectUserId: SUBJECT_USER_ID, + }); + }); + beforeEach(() => { vi.clearAllMocks(); service = new CaService(); @@ -226,9 +241,9 @@ describe('CaService', () => { // Now test that the service's validateCsr accepts it. // We call it indirectly via issueCert with a successful mock. - makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] }); + makeHttpsMock(200, { crt: realIssuedCertPem, certChain: [realIssuedCertPem, FAKE_CA_PEM] }); const result = await service.issueCert(makeReq({ csrPem: realCsrPem })); - expect(result.certPem).toBe(FAKE_CERT_PEM); + expect(result.certPem).toBe(realIssuedCertPem); }); it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => { @@ -251,14 +266,14 @@ describe('CaService', () => { it('returns IssuedCertDto on success (certChain present)', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, { - crt: FAKE_CERT_PEM, - certChain: [FAKE_CERT_PEM, FAKE_CA_PEM], + crt: realIssuedCertPem, + certChain: [realIssuedCertPem, FAKE_CA_PEM], }); const result = await service.issueCert(makeReq()); - expect(result.certPem).toBe(FAKE_CERT_PEM); - expect(result.certChainPem).toContain(FAKE_CERT_PEM); + expect(result.certPem).toBe(realIssuedCertPem); + expect(result.certChainPem).toContain(realIssuedCertPem); expect(result.certChainPem).toContain(FAKE_CA_PEM); expect(typeof result.serialNumber).toBe('string'); }); @@ -270,14 +285,14 @@ describe('CaService', () => { it('builds certChainPem from crt+ca when certChain is absent', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, { - crt: FAKE_CERT_PEM, + crt: realIssuedCertPem, ca: FAKE_CA_PEM, }); const result = await service.issueCert(makeReq()); - expect(result.certPem).toBe(FAKE_CERT_PEM); - expect(result.certChainPem).toContain(FAKE_CERT_PEM); + expect(result.certPem).toBe(realIssuedCertPem); + expect(result.certChainPem).toContain(realIssuedCertPem); expect(result.certChainPem).toContain(FAKE_CA_PEM); }); @@ -287,12 +302,12 @@ describe('CaService', () => { it('falls back to certPem alone when certChain and ca are absent', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); - makeHttpsMock(200, { crt: FAKE_CERT_PEM }); + makeHttpsMock(200, { crt: realIssuedCertPem }); const result = await service.issueCert(makeReq()); - expect(result.certPem).toBe(FAKE_CERT_PEM); - expect(result.certChainPem).toBe(FAKE_CERT_PEM); + expect(result.certPem).toBe(realIssuedCertPem); + expect(result.certChainPem).toBe(realIssuedCertPem); }); // ------------------------------------------------------------------------- @@ -398,7 +413,7 @@ describe('CaService', () => { statusCode: 200, on: (event: string, cb: (chunk?: Buffer) => void) => { if (event === 'data') { - cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); + cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem }))); } if (event === 'end') { cb(); @@ -555,7 +570,7 @@ describe('CaService', () => { statusCode: 200, on: (event: string, cb: (chunk?: Buffer) => void) => { if (event === 'data') { - cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); + cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem }))); } if (event === 'end') { cb();