import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { AcmeServer } from '../ts_server/index.js'; import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js'; const TEST_PORT = 14567; const BASE_URL = `http://localhost:${TEST_PORT}`; let server: AcmeServer; let accountKeyPem: string; let accountUrl: string; let nonce: string; // Helper: simple HTTP request function httpRequest( url: string, method: string, body?: string, headers?: Record, ): Promise<{ status: number; headers: Record; data: any }> { return new Promise((resolve, reject) => { const urlObj = new URL(url); const options: http.RequestOptions = { hostname: urlObj.hostname, port: urlObj.port, path: urlObj.pathname + urlObj.search, method, headers: { ...headers, ...(body ? { 'Content-Length': Buffer.byteLength(body).toString() } : {}), }, }; const req = http.request(options, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf-8'); const responseHeaders: Record = {}; for (const [key, value] of Object.entries(res.headers)) { if (typeof value === 'string') { responseHeaders[key.toLowerCase()] = value; } else if (Array.isArray(value)) { responseHeaders[key.toLowerCase()] = value[0]; } } let data: any; const ct = responseHeaders['content-type'] || ''; if (ct.includes('json') || ct.includes('problem')) { try { data = JSON.parse(raw); } catch { data = raw; } } else { data = raw; } resolve({ status: res.statusCode || 0, headers: responseHeaders, data }); }); }); req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('Timeout'))); if (body) req.write(body); req.end(); }); } // Helper: signed ACME request async function signedRequest( url: string, payload: any | null, options?: { useJwk?: boolean }, ): Promise<{ status: number; headers: Record; data: any }> { const jwsOptions: { nonce: string; kid?: string; jwk?: Record } = { nonce }; if (options?.useJwk) { jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem); } else if (accountUrl) { jwsOptions.kid = accountUrl; } else { jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem); } const jws = AcmeCrypto.createJws(accountKeyPem, url, payload, jwsOptions); const body = JSON.stringify(jws); const response = await httpRequest(url, 'POST', body, { 'Content-Type': 'application/jose+json', }); // Save nonce from response if (response.headers['replay-nonce']) { nonce = response.headers['replay-nonce']; } return response; } // Helper: get a fresh nonce async function fetchNonce(): Promise { const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD'); return res.headers['replay-nonce']; } // ============================================================================ // Tests // ============================================================================ tap.test('server: start ACME server', async () => { server = new AcmeServer({ port: TEST_PORT, baseUrl: BASE_URL, challengeVerification: false, // Auto-approve challenges for testing caOptions: { commonName: 'Test ACME CA', certValidityDays: 30, }, }); await server.start(); }); tap.test('server: GET /directory returns valid directory', async () => { const res = await httpRequest(`${BASE_URL}/directory`, 'GET'); expect(res.status).toEqual(200); expect(res.data.newNonce).toEqual(`${BASE_URL}/new-nonce`); expect(res.data.newAccount).toEqual(`${BASE_URL}/new-account`); expect(res.data.newOrder).toEqual(`${BASE_URL}/new-order`); expect(res.data.meta).toBeTruthy(); }); tap.test('server: HEAD /new-nonce returns Replay-Nonce header', async () => { const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD'); expect(res.status).toEqual(200); expect(res.headers['replay-nonce']).toBeTruthy(); }); tap.test('server: GET /new-nonce returns 204 with Replay-Nonce header', async () => { const res = await httpRequest(`${BASE_URL}/new-nonce`, 'GET'); expect(res.status).toEqual(204); expect(res.headers['replay-nonce']).toBeTruthy(); }); tap.test('server: POST /new-account registers an account', async () => { accountKeyPem = AcmeCrypto.createRsaPrivateKey(); nonce = await fetchNonce(); const res = await signedRequest(`${BASE_URL}/new-account`, { termsOfServiceAgreed: true, contact: ['mailto:test@example.com'], }, { useJwk: true }); expect(res.status).toEqual(201); expect(res.headers['location']).toBeTruthy(); expect(res.data.status).toEqual('valid'); accountUrl = res.headers['location']; }); tap.test('server: POST /new-account returns existing account', async () => { const res = await signedRequest(`${BASE_URL}/new-account`, { termsOfServiceAgreed: true, contact: ['mailto:test@example.com'], }, { useJwk: true }); expect(res.status).toEqual(200); expect(res.headers['location']).toEqual(accountUrl); }); tap.test('server: full certificate issuance flow', async () => { // 1. Create order const orderRes = await signedRequest(`${BASE_URL}/new-order`, { identifiers: [{ type: 'dns', value: 'example.com' }], }); expect(orderRes.status).toEqual(201); expect(orderRes.data.status).toEqual('pending'); expect(orderRes.data.authorizations).toBeTypeOf('object'); expect(orderRes.data.finalize).toBeTruthy(); const orderUrl = orderRes.headers['location']; const authzUrl = orderRes.data.authorizations[0]; const finalizeUrl = orderRes.data.finalize; // 2. Get authorization const authzRes = await signedRequest(authzUrl, null); expect(authzRes.status).toEqual(200); expect(authzRes.data.identifier.value).toEqual('example.com'); expect(authzRes.data.challenges.length).toBeGreaterThan(0); // Pick a challenge (prefer dns-01 since it's always available) const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01') || authzRes.data.challenges[0]; expect(challenge.status).toEqual('pending'); // 3. Trigger challenge (auto-approved since challengeVerification is false) const challengeRes = await signedRequest(challenge.url, {}); expect(challengeRes.status).toEqual(200); expect(challengeRes.data.status).toEqual('valid'); // 4. Poll order — should now be ready const orderPollRes = await signedRequest(orderUrl, null); expect(orderPollRes.status).toEqual(200); expect(orderPollRes.data.status).toEqual('ready'); // 5. Create CSR and finalize const [keyPem, csrPem] = await AcmeCrypto.createCsr({ commonName: 'example.com', altNames: ['example.com'], }); const csrDer = AcmeCrypto.pemToBuffer(csrPem); const csrB64url = csrDer.toString('base64url'); const finalizeRes = await signedRequest(finalizeUrl, { csr: csrB64url }); expect(finalizeRes.status).toEqual(200); expect(finalizeRes.data.status).toEqual('valid'); expect(finalizeRes.data.certificate).toBeTruthy(); // 6. Download certificate const certUrl = finalizeRes.data.certificate; const certRes = await signedRequest(certUrl, null); expect(certRes.status).toEqual(200); expect(certRes.headers['content-type']).toEqual('application/pem-certificate-chain'); expect(certRes.data).toInclude('BEGIN CERTIFICATE'); // Verify it's a valid PEM chain (at least 2 certs: end-entity + CA) const certMatches = (certRes.data as string).match(/-----BEGIN CERTIFICATE-----/g); expect(certMatches!.length).toBeGreaterThan(1); }); tap.test('server: wildcard certificate issuance', async () => { // Create order with wildcard const orderRes = await signedRequest(`${BASE_URL}/new-order`, { identifiers: [ { type: 'dns', value: 'test.org' }, { type: 'dns', value: '*.test.org' }, ], }); expect(orderRes.status).toEqual(201); expect(orderRes.data.authorizations.length).toEqual(2); const orderUrl = orderRes.headers['location']; // Complete all challenges for (const authzUrl of orderRes.data.authorizations) { const authzRes = await signedRequest(authzUrl, null); // For wildcard, only dns-01 should be available const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01'); expect(challenge).toBeTruthy(); await signedRequest(challenge.url, {}); } // Poll order const orderPollRes = await signedRequest(orderUrl, null); expect(orderPollRes.data.status).toEqual('ready'); // Finalize const [keyPem, csrPem] = await AcmeCrypto.createCsr({ commonName: 'test.org', altNames: ['test.org', '*.test.org'], }); const csrDer = AcmeCrypto.pemToBuffer(csrPem); const finalizeRes = await signedRequest(orderPollRes.data.finalize, { csr: csrDer.toString('base64url'), }); expect(finalizeRes.data.status).toEqual('valid'); // Download cert const certRes = await signedRequest(finalizeRes.data.certificate, null); expect(certRes.data).toInclude('BEGIN CERTIFICATE'); }); tap.test('server: rejects invalid JWS signature', async () => { // Create JWS with one key but sign URL intended for a different key const otherKey = AcmeCrypto.createRsaPrivateKey(); const otherNonce = await fetchNonce(); const jws = AcmeCrypto.createJws(otherKey, `${BASE_URL}/new-order`, { identifiers: [] }, { nonce: otherNonce, kid: accountUrl, // Use our account URL but sign with a different key }); const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), { 'Content-Type': 'application/jose+json', }); // Save nonce for next request if (res.headers['replay-nonce']) { nonce = res.headers['replay-nonce']; } expect(res.status).toEqual(403); }); tap.test('server: rejects expired/bad nonce', async () => { const jws = AcmeCrypto.createJws( accountKeyPem, `${BASE_URL}/new-order`, { identifiers: [{ type: 'dns', value: 'example.com' }] }, { nonce: 'definitely-not-a-valid-nonce', kid: accountUrl }, ); const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), { 'Content-Type': 'application/jose+json', }); if (res.headers['replay-nonce']) { nonce = res.headers['replay-nonce']; } expect(res.status).toEqual(400); expect(res.data.type).toEqual('urn:ietf:params:acme:error:badNonce'); }); tap.test('server: finalize rejects order not in ready state', async () => { // Create a new order but don't complete challenges const orderRes = await signedRequest(`${BASE_URL}/new-order`, { identifiers: [{ type: 'dns', value: 'pending.example.com' }], }); const [keyPem, csrPem] = await AcmeCrypto.createCsr({ commonName: 'pending.example.com', }); const csrDer = AcmeCrypto.pemToBuffer(csrPem); const finalizeRes = await signedRequest(orderRes.data.finalize, { csr: csrDer.toString('base64url'), }); expect(finalizeRes.status).toEqual(403); expect(finalizeRes.data.type).toEqual('urn:ietf:params:acme:error:orderNotReady'); }); tap.test('server: getCaCertPem returns root CA certificate', async () => { const caPem = server.getCaCertPem(); expect(caPem).toInclude('BEGIN CERTIFICATE'); }); tap.test('server: stop ACME server', async () => { await server.stop(); }); export default tap.start();