feat(federation): Step-CA client service for grant certs (FED-M2-04)
- Add CaService (@Injectable) that POSTs CSRs to step-ca /1.0/sign over HTTPS with a pinned CA root cert; builds HS256 OTT with custom claims mosaic_grant_id and mosaic_subject_user_id plus step.sha CSR fingerprint - Add CaServiceError with cause + remediation for fail-loud contract - Add IssueCertRequestDto and IssuedCertDto with class-validator decorators - Add FederationModule exporting CaService; wire into AppModule - Replace federation.tpl TODO placeholder with real step-ca Go template emitting OID 1.3.6.1.4.1.99999.1 (grantId) and .2 (subjectUserId) as DER UTF8String extensions (tag 0x0C, length 0x24, base64-encoded value) - Update infra/step-ca/init.sh to patch mosaic-fed provisioner config with templateFile path via jq on first boot (idempotent) - Append OID assignment registry and CA env var table to docs/federation/SETUP.md - 11 unit tests pass: happy path, certChain fallbacks, HTTP 401/4xx, malformed CSR (no HTTP call), non-JSON response, connection error, JWT claim assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
360
apps/gateway/src/federation/ca.service.spec.ts
Normal file
360
apps/gateway/src/federation/ca.service.spec.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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> = {}): 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<string, unknown> | undefined;
|
||||
|
||||
// Override the mock to capture the request body
|
||||
const mockReq = {
|
||||
write: vi.fn((data: string) => {
|
||||
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||
}),
|
||||
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<string, unknown>;
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user