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 }; }) { 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(), 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();