/** * 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 * - 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 * - CaServiceError: has cause + remediation properties * - Missing crt in response: throws CaServiceError */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- // Minimal self-signed certificate PEM produced by openssl for testing. // Serial 01, RSA 512 bit (invalid for production, fine for unit tests). const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA -----END CERTIFICATE-----\n`; const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA -----END CERTIFICATE REQUEST-----\n`; const FAKE_CA_PEM = `-----BEGIN CERTIFICATE----- CAROOT000000000000000000000000000000000000000000000000AAAA -----END CERTIFICATE-----\n`; const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22'; // --------------------------------------------------------------------------- // Setup env before importing service // --------------------------------------------------------------------------- const JWK_KEY = JSON.stringify({ kty: 'oct', kid: 'test-kid', k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret") }); process.env['STEP_CA_URL'] = 'https://step-ca:9000'; process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'test-password'; process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JWK_KEY; 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(), }; (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 { return { csrPem: FAKE_CSR_PEM, grantId: GRANT_ID, subjectUserId: SUBJECT_USER_ID, ttlSeconds: 86400, ...overrides, }; } // ------------------------------------------------------------------------- // Happy path // ------------------------------------------------------------------------- it('returns IssuedCertDto on success (certChain present)', async () => { 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 () => { 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 () => { 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 () => { 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 () => { 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 () => { 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 () => { 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 // ------------------------------------------------------------------------- it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => { let capturedBody: Record | undefined; // Override the mock to capture the request body const mockReq = { write: vi.fn((data: string) => { capturedBody = JSON.parse(data) as Record; }), end: vi.fn(), on: 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()); expect(capturedBody).toBeDefined(); const ott = capturedBody!['ott'] as string; expect(typeof ott).toBe('string'); // Decode JWT payload (second segment) const parts = ott.split('.'); expect(parts).toHaveLength(3); 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); }); // ------------------------------------------------------------------------- // 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 () => { 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; }); }); });