578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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> = {}): 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<string, unknown> | undefined;
|
|
|
|
const mockReq = {
|
|
write: vi.fn((data: string) => {
|
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
|
}),
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown> | 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<string, unknown> | undefined;
|
|
|
|
const mockReq = {
|
|
write: vi.fn((data: string) => {
|
|
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
|
}),
|
|
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<string, unknown>;
|
|
expect(validity['duration']).toBe('900s');
|
|
});
|
|
});
|