/** * Unit tests for CaService — Step-CA client (FED-M2-04). * * Coverage: * - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber * - certChainPem fallback: falls back to certPem when certChain absent * - certChainPem from ca field: uses crt+ca when certChain absent but ca present * - HTTP 401: throws CaServiceError with cause + remediation * - HTTP non-401 error: throws CaServiceError * - Malformed CSR: throws before HTTP call (INVALID_CSR) * - Non-JSON response: throws CaServiceError * - HTTPS connection error: throws CaServiceError * - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload * verified with jose.jwtVerify (real signature check) * - CaServiceError: has cause + remediation properties * - Missing crt in response: throws CaServiceError * - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR * - provisionerPassword never appears in CaServiceError messages * - HTTPS-only enforcement: http:// URL throws in constructor */ import 'reflect-metadata'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { jwtVerify, exportJWK, generateKeyPair } from 'jose'; import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509'; // --------------------------------------------------------------------------- // Mock node:https BEFORE importing CaService so the mock is in place when // the module is loaded. Vitest/ESM require vi.mock at the top level. // --------------------------------------------------------------------------- vi.mock('node:https', () => { const mockRequest = vi.fn(); const mockAgent = vi.fn().mockImplementation(() => ({})); return { default: { request: mockRequest, Agent: mockAgent }, request: mockRequest, Agent: mockAgent, }; }); vi.mock('node:fs', () => { const mockReadFileSync = vi .fn() .mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n'); return { default: { readFileSync: mockReadFileSync }, readFileSync: mockReadFileSync, }; }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Real self-signed EC P-256 certificate generated with openssl for testing. // openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \ // -out /dev/stdout -subj "/CN=test" -days 1 const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE----- MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4 MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB /zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw== -----END CERTIFICATE----- `; // Use a second copy of the same cert for the CA field in tests. const FAKE_CA_PEM = FAKE_CERT_PEM; const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22'; // --------------------------------------------------------------------------- // Generate a real EC P-256 key pair and CSR for integration-style tests // --------------------------------------------------------------------------- // We generate this once at module level so it's available to all tests. // The key pair and CSR PEM are populated asynchronously in the test that needs them. let realCsrPem: string; async function generateRealCsr(): Promise { const { privateKey, publicKey } = await generateKeyPair('ES256'); // Export public key JWK for potential verification (not used here but confirms key is exportable) await exportJWK(publicKey); // Use @peculiar/x509 to build a proper CSR const csr = await Pkcs10CertificateRequestGenerator.create({ name: 'CN=test.federation.local', signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, keys: { privateKey, publicKey }, }); return csr.toString('pem'); } // --------------------------------------------------------------------------- // Setup env before importing service // We use an EC P-256 key pair here so the JWK-based signing works. // The key pair is generated once and stored in module-level vars. // --------------------------------------------------------------------------- // Real EC P-256 test JWK (test-only, never used in production). // Generated with node webcrypto for use in unit tests. const TEST_EC_PRIVATE_JWK = { key_ops: ['sign'], ext: true, kty: 'EC', x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU', y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E', crv: 'P-256', d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8', kid: 'test-ec-kid', }; const TEST_EC_PUBLIC_JWK = { key_ops: ['verify'], ext: true, kty: 'EC', x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU', y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E', crv: 'P-256', kid: 'test-ec-kid', }; process.env['STEP_CA_URL'] = 'https://step-ca:9000'; process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK); process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem'; // Import AFTER env is set and mocks are registered import * as httpsModule from 'node:https'; import { CaService, CaServiceError } from './ca.service.js'; import type { IssueCertRequestDto } from './ca.dto.js'; // --------------------------------------------------------------------------- // Helper to build a mock https.request that simulates step-ca // --------------------------------------------------------------------------- function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void { const mockReq = { write: vi.fn(), end: vi.fn(), on: vi.fn(), setTimeout: vi.fn(), }; (httpsModule.request as unknown as Mock).mockImplementation( ( _options: unknown, callback: (res: { statusCode: number; on: (event: string, cb: (chunk?: Buffer) => void) => void; }) => void, ) => { const mockRes = { statusCode, on: (event: string, cb: (chunk?: Buffer) => void) => { if (event === 'data') { if (body !== undefined) { cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body))); } } if (event === 'end') { cb(); } }, }; if (errorMsg) { // Simulate a connection error via the req.on('error') handler mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => { if (event === 'error') { setImmediate(() => cb(new Error(errorMsg))); } }); } else { // Normal flow: call the response callback setImmediate(() => callback(mockRes)); } return mockReq; }, ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('CaService', () => { let service: CaService; beforeEach(() => { vi.clearAllMocks(); service = new CaService(); }); function makeReq(overrides: Partial = {}): IssueCertRequestDto { // Use a real CSR if available; fall back to a minimal placeholder const defaultCsr = realCsrPem ?? makeFakeCsr(); return { csrPem: defaultCsr, grantId: GRANT_ID, subjectUserId: SUBJECT_USER_ID, ttlSeconds: 300, ...overrides, }; } function makeFakeCsr(): string { // A structurally valid-looking CSR header/footer (body will fail crypto verify) return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`; } // ------------------------------------------------------------------------- // Real CSR generation — runs once and populates realCsrPem // ------------------------------------------------------------------------- it('generates a real P-256 CSR that passes validateCsr', async () => { realCsrPem = await generateRealCsr(); expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/); // 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] }); const result = await service.issueCert(makeReq({ csrPem: realCsrPem })); expect(result.certPem).toBe(FAKE_CERT_PEM); }); it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => { const malformedCsr = '-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n'; await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy( (err: unknown) => { if (!(err instanceof CaServiceError)) return false; expect(err.code).toBe('INVALID_CSR'); return true; }, ); }); // ------------------------------------------------------------------------- // Happy path // ------------------------------------------------------------------------- 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], }); const result = await service.issueCert(makeReq()); expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certChainPem).toContain(FAKE_CERT_PEM); expect(result.certChainPem).toContain(FAKE_CA_PEM); expect(typeof result.serialNumber).toBe('string'); }); // ------------------------------------------------------------------------- // certChainPem fallback — certChain absent, ca field present // ------------------------------------------------------------------------- it('builds certChainPem from crt+ca when certChain is absent', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, { crt: FAKE_CERT_PEM, 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.certChainPem).toContain(FAKE_CA_PEM); }); // ------------------------------------------------------------------------- // certChainPem fallback — no certChain, no ca field // ------------------------------------------------------------------------- it('falls back to certPem alone when certChain and ca are absent', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, { crt: FAKE_CERT_PEM }); const result = await service.issueCert(makeReq()); expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certChainPem).toBe(FAKE_CERT_PEM); }); // ------------------------------------------------------------------------- // HTTP 401 // ------------------------------------------------------------------------- it('throws CaServiceError on HTTP 401', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(401, { message: 'Unauthorized' }); await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => { if (!(err instanceof CaServiceError)) return false; expect(err.message).toMatch(/401/); expect(err.remediation).toBeTruthy(); return true; }); }); // ------------------------------------------------------------------------- // HTTP non-401 error (e.g. 422) // ------------------------------------------------------------------------- it('throws CaServiceError on HTTP 422', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(422, { message: 'Unprocessable Entity' }); await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError); }); // ------------------------------------------------------------------------- // Malformed CSR — throws before HTTP call // ------------------------------------------------------------------------- it('throws CaServiceError for malformed CSR without making HTTP call', async () => { const requestSpy = vi.spyOn(httpsModule, 'request'); await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf( CaServiceError, ); expect(requestSpy).not.toHaveBeenCalled(); }); // ------------------------------------------------------------------------- // Non-JSON response // ------------------------------------------------------------------------- it('throws CaServiceError when step-ca returns non-JSON', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, 'this is not json'); await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => { if (!(err instanceof CaServiceError)) return false; expect(err.message).toMatch(/non-JSON/); return true; }); }); // ------------------------------------------------------------------------- // HTTPS connection error // ------------------------------------------------------------------------- it('throws CaServiceError on HTTPS connection error', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000'); await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => { if (!(err instanceof CaServiceError)) return false; expect(err.message).toMatch(/HTTPS connection/); expect(err.cause).toBeInstanceOf(Error); return true; }); }); // ------------------------------------------------------------------------- // JWT custom claims: mosaic_grant_id and mosaic_subject_user_id // Verified with jose.jwtVerify for real signature verification (M6) // ------------------------------------------------------------------------- it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); let capturedBody: Record | undefined; const mockReq = { write: vi.fn((data: string) => { capturedBody = JSON.parse(data) as Record; }), end: vi.fn(), on: vi.fn(), setTimeout: vi.fn(), }; (httpsModule.request as unknown as Mock).mockImplementation( ( _options: unknown, callback: (res: { statusCode: number; on: (event: string, cb: (chunk?: Buffer) => void) => void; }) => void, ) => { const mockRes = { statusCode: 200, on: (event: string, cb: (chunk?: Buffer) => void) => { if (event === 'data') { cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); } if (event === 'end') { cb(); } }, }; setImmediate(() => callback(mockRes)); return mockReq; }, ); await service.issueCert(makeReq({ csrPem: realCsrPem })); expect(capturedBody).toBeDefined(); const ott = capturedBody!['ott'] as string; expect(typeof ott).toBe('string'); // Verify JWT structure const parts = ott.split('.'); expect(parts).toHaveLength(3); // Decode payload without signature check first const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8'); const payload = JSON.parse(payloadJson) as Record; expect(payload['mosaic_grant_id']).toBe(GRANT_ID); expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID); expect(typeof payload['jti']).toBe('string'); // M2: jti present expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format // M3: top-level sha should NOT be present; step.sha should be present expect(payload['sha']).toBeUndefined(); const step = payload['step'] as Record | undefined; expect(step?.['sha']).toBeDefined(); // M6: Verify signature with jose.jwtVerify using the public key const { importJWK: importJose } = await import('jose'); const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256'); const verified = await jwtVerify(ott, publicKey); expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID); }); // ------------------------------------------------------------------------- // CaServiceError has cause + remediation // ------------------------------------------------------------------------- it('CaServiceError carries cause and remediation', () => { const cause = new Error('original error'); const err = new CaServiceError('something went wrong', 'fix it like this', cause); expect(err).toBeInstanceOf(Error); expect(err).toBeInstanceOf(CaServiceError); expect(err.message).toBe('something went wrong'); expect(err.remediation).toBe('fix it like this'); expect(err.cause).toBe(cause); expect(err.name).toBe('CaServiceError'); }); // ------------------------------------------------------------------------- // Missing crt in response // ------------------------------------------------------------------------- it('throws CaServiceError when response is missing the crt field', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(200, { ca: FAKE_CA_PEM }); await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => { if (!(err instanceof CaServiceError)) return false; expect(err.message).toMatch(/missing the "crt" field/); return true; }); }); // ------------------------------------------------------------------------- // M6: provisionerPassword must never appear in CaServiceError messages // ------------------------------------------------------------------------- it('provisionerPassword does not appear in any CaServiceError message', async () => { // Temporarily set a recognizable password to test against const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD']; process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345'; // Generate a bad CSR to trigger an error path const caughtErrors: CaServiceError[] = []; try { await service.issueCert(makeReq({ csrPem: 'not-a-csr' })); } catch (err) { if (err instanceof CaServiceError) { caughtErrors.push(err); } } // Also try HTTP 401 path if (!realCsrPem) realCsrPem = await generateRealCsr(); makeHttpsMock(401, { message: 'Unauthorized' }); try { await service.issueCert(makeReq({ csrPem: realCsrPem })); } catch (err) { if (err instanceof CaServiceError) { caughtErrors.push(err); } } for (const err of caughtErrors) { expect(err.message).not.toContain('super-secret-password-12345'); if (err.remediation) { expect(err.remediation).not.toContain('super-secret-password-12345'); } } process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword; }); // ------------------------------------------------------------------------- // M7: HTTPS-only enforcement in constructor // ------------------------------------------------------------------------- it('throws in constructor if STEP_CA_URL uses http://', () => { const originalUrl = process.env['STEP_CA_URL']; process.env['STEP_CA_URL'] = 'http://step-ca:9000'; expect(() => new CaService()).toThrow(CaServiceError); process.env['STEP_CA_URL'] = originalUrl; }); // ------------------------------------------------------------------------- // TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum // ------------------------------------------------------------------------- it('clamps ttlSeconds to 900 s regardless of input', async () => { if (!realCsrPem) realCsrPem = await generateRealCsr(); let capturedBody: Record | undefined; const mockReq = { write: vi.fn((data: string) => { capturedBody = JSON.parse(data) as Record; }), end: vi.fn(), on: vi.fn(), setTimeout: vi.fn(), }; (httpsModule.request as unknown as Mock).mockImplementation( ( _options: unknown, callback: (res: { statusCode: number; on: (event: string, cb: (chunk?: Buffer) => void) => void; }) => void, ) => { const mockRes = { statusCode: 200, on: (event: string, cb: (chunk?: Buffer) => void) => { if (event === 'data') { cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); } if (event === 'end') { cb(); } }, }; setImmediate(() => callback(mockRes)); return mockReq; }, ); // Request 86400 s — should be clamped to 900 await service.issueCert(makeReq({ ttlSeconds: 86400 })); expect(capturedBody).toBeDefined(); const validity = capturedBody!['validity'] as Record; expect(validity['duration']).toBe('900s'); }); });