fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
This commit is contained in:
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user