197 lines
8.6 KiB
TypeScript
197 lines
8.6 KiB
TypeScript
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||
|
|
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
|
||
|
|
// Used by the force-renew sibling-propagation logic to identify which routes
|
||
|
|
// share a single underlying ACME certificate.
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
|
||
|
|
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
|
||
|
|
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
|
||
|
|
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
|
||
|
|
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
|
||
|
|
expect(deriveCertDomainName('example.com')).toEqual('example.com');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
|
||
|
|
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
|
||
|
|
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
|
||
|
|
// This is the core property: outline.task.vc and *.task.vc must yield
|
||
|
|
// the same cert identity, otherwise sibling propagation cannot work.
|
||
|
|
const subdomain = deriveCertDomainName('outline.task.vc');
|
||
|
|
const wildcard = deriveCertDomainName('*.task.vc');
|
||
|
|
expect(subdomain).toEqual(wildcard);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
|
||
|
|
// Matches smartacme's "deeper domains not supported" behavior.
|
||
|
|
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
|
||
|
|
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
|
||
|
|
expect(deriveCertDomainName('vc')).toBeUndefined();
|
||
|
|
expect(deriveCertDomainName('')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
|
||
|
|
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
|
||
|
|
//
|
||
|
|
// This is the regression test for Bug 1: previously the call passed only
|
||
|
|
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
|
||
|
|
// and break every sibling subdomain.
|
||
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||
|
|
|
||
|
|
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
|
||
|
|
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
|
||
|
|
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
|
||
|
|
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
|
||
|
|
function makeStubOpsServer(opts: {
|
||
|
|
routes: Array<{ name: string; domains: string[] }>;
|
||
|
|
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
|
||
|
|
}) {
|
||
|
|
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
|
||
|
|
const router = {
|
||
|
|
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
|
||
|
|
};
|
||
|
|
|
||
|
|
const routes = opts.routes.map((r) => ({
|
||
|
|
name: r.name,
|
||
|
|
match: { domains: r.domains, ports: 443 },
|
||
|
|
action: { type: 'forward', tls: { certificate: 'auto' } },
|
||
|
|
}));
|
||
|
|
|
||
|
|
const dcRouterRef: any = {
|
||
|
|
smartProxy: {
|
||
|
|
routeManager: { getRoutes: () => routes },
|
||
|
|
},
|
||
|
|
smartAcme: opts.smartAcmeStub,
|
||
|
|
findRouteNamesForDomain: (domain: string) =>
|
||
|
|
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
|
||
|
|
certificateStatusMap: new Map<string, any>(),
|
||
|
|
certProvisionScheduler: null,
|
||
|
|
routeConfigManager: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
const opsServerRef: any = {
|
||
|
|
viewRouter: router,
|
||
|
|
adminRouter: router,
|
||
|
|
dcRouterRef,
|
||
|
|
};
|
||
|
|
|
||
|
|
return { opsServerRef, dcRouterRef, captured };
|
||
|
|
}
|
||
|
|
|
||
|
|
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
|
||
|
|
const calls: Array<{ domain: string; options: any }> = [];
|
||
|
|
|
||
|
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||
|
|
routes: [
|
||
|
|
{ name: 'outline-route', domains: ['outline.task.vc'] },
|
||
|
|
{ name: 'pr-route', domains: ['pr.task.vc'] },
|
||
|
|
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
|
||
|
|
],
|
||
|
|
smartAcmeStub: {
|
||
|
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||
|
|
calls.push({ domain, options });
|
||
|
|
// Return a cert object shaped like SmartacmeCert
|
||
|
|
return {
|
||
|
|
id: 'test-id',
|
||
|
|
domainName: 'task.vc',
|
||
|
|
created: Date.now(),
|
||
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||
|
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||
|
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||
|
|
csr: '',
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
|
||
|
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||
|
|
|
||
|
|
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
|
||
|
|
const handler = new CertificateHandler(opsServerRef);
|
||
|
|
|
||
|
|
// Invoke the private reprovision method directly. The Bug 1 fix is verified
|
||
|
|
// by inspecting the captured smartAcme call options regardless of whether
|
||
|
|
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
|
||
|
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
|
||
|
|
|
||
|
|
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
|
||
|
|
// The Bug 1 fix is verified by the captured smartAcme call regardless.
|
||
|
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||
|
|
expect(calls[0].domain).toEqual('outline.task.vc');
|
||
|
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
|
||
|
|
const calls: Array<{ domain: string; options: any }> = [];
|
||
|
|
|
||
|
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||
|
|
routes: [
|
||
|
|
{ name: 'wildcard-route', domains: ['*.task.vc'] },
|
||
|
|
],
|
||
|
|
smartAcmeStub: {
|
||
|
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||
|
|
calls.push({ domain, options });
|
||
|
|
return {
|
||
|
|
id: 'test-id',
|
||
|
|
domainName: 'task.vc',
|
||
|
|
created: Date.now(),
|
||
|
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||
|
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||
|
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||
|
|
csr: '',
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||
|
|
|
||
|
|
const handler = new CertificateHandler(opsServerRef);
|
||
|
|
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
|
||
|
|
|
||
|
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||
|
|
expect(calls[0].domain).toEqual('*.task.vc');
|
||
|
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
|
||
|
|
const calls: Array<{ domain: string; options: any }> = [];
|
||
|
|
|
||
|
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||
|
|
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
|
||
|
|
smartAcmeStub: {
|
||
|
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||
|
|
calls.push({ domain, options });
|
||
|
|
return {} as any;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||
|
|
|
||
|
|
const handler = new CertificateHandler(opsServerRef);
|
||
|
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
|
||
|
|
|
||
|
|
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
|
||
|
|
// applyRoutes and lets the cert provisioning pipeline handle it.
|
||
|
|
expect(calls.length).toEqual(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|