Files
smartacme/test/test.acme-server.node+bun+deno.ts

337 lines
11 KiB
TypeScript

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<string, string>,
): Promise<{ status: number; headers: Record<string, string>; 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<string, string> = {};
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<string, string>; data: any }> {
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { 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<string> {
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();