import { afterEach, describe, expect, it, vi } from "vitest"; import { repos } from "@cfdm/db"; import { CERT_ERROR, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, } from "@cfdm/shared"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import * as certificateService from "../src/services/certificate-service.js"; async function authHeaders(app: Awaited>) { const config = loadConfig(); const res = await app.inject({ method: "POST", url: "/api/v1/auth/login", payload: { username: config.adminUsername, password: "admin" }, }); expect(res.statusCode).toBe(200); const { token } = res.json() as { token: string }; return { authorization: `Bearer ${token}` }; } describe("certificates", () => { afterEach(() => { vi.restoreAllMocks(); }); it("auto mode does not monitor DNS-only domain", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "dns-only.example.com", "cf-zone-dns", ); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, error: "connection refused", }); const checkRes = await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect(checkRes.statusCode).toBe(200); const certs = repos.listCertificates(testApp.db); expect(certs.find((c) => c.hostname === domain.zone_name)).toBeUndefined(); await testApp.close(); }); it("monitors host with enabled service binding", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "app.example.com", "cf-zone-app", ); const service = repos.createService(testApp.db, "Web", "web"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "api", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { health_check_enabled: true, health_check_verify_tls: true, }); const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt, error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); const certs = repos.listCertificates(testApp.db); expect(certs.some((c) => c.hostname === "api.app.example.com")).toBe(true); expect(certs.some((c) => c.hostname === "app.example.com")).toBe(false); await testApp.close(); }); it("does not monitor binding when health-check is off", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "rkns.example.com", "cf-zone-imsk", ); const service = repos.createService(testApp.db, "Cname", "cname"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "imsk", null, ); repos.setBindingCnameTarget(testApp.db, binding.id, "ihome.rkns.example.com"); repos.updateBindingLbConfig(testApp.db, binding.id, { health_check_enabled: false, }); repos.upsertCertificateCheck( testApp.db, domain.id, null, "imsk.rkns.example.com", null, CERT_ERROR, "stale", ); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect( repos.listCertificates(testApp.db).some( (c) => c.hostname === "imsk.rkns.example.com", ), ).toBe(false); expect(certificateService.checkHostname).not.toHaveBeenCalled(); const listRes = await testApp.inject({ method: "GET", url: "/api/v1/certificates", headers, }); expect(listRes.statusCode).toBe(200); expect( (listRes.json() as { hostname: string }[]).some( (c) => c.hostname === "imsk.rkns.example.com", ), ).toBe(false); await testApp.close(); }); it("does not monitor host when service is disabled", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "off.example.com", "cf-zone-off", ); const service = repos.createService(testApp.db, "Off", "off"); repos.insertBinding(testApp.db, domain.id, service.id, "@", null); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect( repos.listCertificates(testApp.db).some((c) => c.hostname === domain.zone_name), ).toBe(false); await testApp.close(); }); it("required binding is monitored without TLS health gate", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "required.example.com", "cf-zone-req", ); const service = repos.createService(testApp.db, "Req", "req"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "@", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, health_check_enabled: false, }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect( repos.listCertificates(testApp.db).some( (c) => c.hostname === domain.zone_name, ), ).toBe(true); await testApp.close(); }); it("skipped binding removes stale certificate on check", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "skipped.example.com", "cf-zone-skip", ); const service = repos.createService(testApp.db, "Skip", "skip"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "@", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_SKIPPED, health_check_enabled: true, health_check_verify_tls: true, }); repos.upsertCertificateCheck( testApp.db, domain.id, null, domain.zone_name, null, CERT_ERROR, "stale", service.id, ); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, error: "should not be called", }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect(repos.listCertificates(testApp.db)).toHaveLength(0); expect(certificateService.checkHostname).not.toHaveBeenCalled(); await testApp.close(); }); it("TLS failure on monitored host is stored as error", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "broken.example.com", "cf-zone-broken", ); const service = repos.createService(testApp.db, "Broken", "broken"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "@", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, error: "certificate has expired", }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); const cert = repos.listCertificates(testApp.db)[0]; expect(cert?.status).toBe(CERT_ERROR); expect(cert?.last_error).toBeTruthy(); await testApp.close(); }); it("skips SSL monitoring when group health is on without TLS verify", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "rkns.example.com", "cf-zone-rkns", ); const group = repos.createServiceGroup( testApp.db, "TG Proxy", "vpn", null, "gt.rkns.example.com", { health_check_enabled: true, health_check_type: "http", health_check_port: 443, health_check_verify_tls: false, }, ); const service = repos.createService(testApp.db, "Node", "node"); repos.setServiceEnabled(testApp.db, service.id, true); repos.setServiceGroup(testApp.db, service.id, group.id); repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); const certs = repos.listCertificates(testApp.db); expect(certs.some((c) => c.hostname === "gt.rkns.example.com")).toBe(false); expect(certs.some((c) => c.hostname === "rutg.rkns.example.com")).toBe( false, ); expect(certificateService.checkHostname).not.toHaveBeenCalled(); await testApp.close(); }); it("monitors group hosts when health TLS verify is on", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "ok.example.com", "cf-zone-ok", ); const group = repos.createServiceGroup( testApp.db, "LB", "vpn", null, "lb.ok.example.com", { health_check_enabled: true, health_check_type: "http", health_check_port: 443, health_check_verify_tls: true, }, ); const service = repos.createService(testApp.db, "Edge", "edge"); repos.setServiceEnabled(testApp.db, service.id, true); repos.setServiceGroup(testApp.db, service.id, group.id); repos.insertBinding(testApp.db, domain.id, service.id, "edge", null); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); const certs = repos.listCertificates(testApp.db); expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false); expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true); await testApp.close(); }); it("required mode still monitors when group skips TLS verify", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "force.example.com", "cf-zone-force", ); const group = repos.createServiceGroup( testApp.db, "Proxy", "vpn", null, "force.example.com", { health_check_enabled: true, health_check_verify_tls: false, }, ); const service = repos.createService(testApp.db, "Force", "force"); repos.setServiceEnabled(testApp.db, service.id, true); repos.setServiceGroup(testApp.db, service.id, group.id); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "@", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); await testApp.inject({ method: "POST", url: "/api/v1/certificates/check", headers, }); expect( repos.listCertificates(testApp.db).some( (c) => c.hostname === domain.zone_name, ), ).toBe(true); await testApp.close(); }); it("GET /certificates prunes stale rows without running check", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "stale-list.example.com", "cf-zone-stale-list", ); const group = repos.createServiceGroup( testApp.db, "Stale", "vpn", null, "gt.stale-list.example.com", { health_check_enabled: true, health_check_verify_tls: false, }, ); const service = repos.createService(testApp.db, "S", "s"); repos.setServiceEnabled(testApp.db, service.id, true); repos.setServiceGroup(testApp.db, service.id, group.id); repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null); repos.upsertCertificateCheck( testApp.db, domain.id, null, "gt.stale-list.example.com", null, CERT_ERROR, "stale group domain", ); repos.upsertCertificateCheck( testApp.db, domain.id, null, "rutg.stale-list.example.com", null, CERT_ERROR, "stale binding", ); expect(repos.listCertificates(testApp.db)).toHaveLength(2); const listRes = await testApp.inject({ method: "GET", url: "/api/v1/certificates", headers, }); expect(listRes.statusCode).toBe(200); expect(listRes.json()).toEqual([]); await testApp.close(); }); it("GET /services/:id/certificates lists binding FQDNs", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "svc.example.com", "cf-zone-svc", ); const service = repos.createService(testApp.db, "Api", "api"); repos.setServiceEnabled(testApp.db, service.id, true); repos.insertBinding(testApp.db, domain.id, service.id, "www", null); const res = await testApp.inject({ method: "GET", url: `/api/v1/services/${service.id}/certificates`, headers, }); expect(res.statusCode).toBe(200); const rows = res.json() as Array<{ hostname: string; cert_monitoring: string; status: string; }>; expect(rows).toHaveLength(1); expect(rows[0]?.hostname).toBe("www.svc.example.com"); expect(rows[0]?.cert_monitoring).toBe("auto"); expect(rows[0]?.status).toBe("unknown"); await testApp.close(); }); it("PATCH /service-bindings/:id updates cert_monitoring", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "patch.example.com", "cf-zone-patch", ); const service = repos.createService(testApp.db, "Patch", "patch"); repos.setServiceEnabled(testApp.db, service.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "api", null, ); const res = await testApp.inject({ method: "PATCH", url: `/api/v1/service-bindings/${binding.id}`, headers, payload: { cert_monitoring: CERT_MONITOR_REQUIRED }, }); expect(res.statusCode).toBe(200); expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe( CERT_MONITOR_REQUIRED, ); expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe( CERT_MONITOR_REQUIRED, ); await testApp.close(); }); it("POST /services/:id/certificates/check only checks that service", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(testApp); const domain = repos.createDomain( testApp.db, null, "check.example.com", "cf-zone-check", ); const service = repos.createService(testApp.db, "One", "one"); const other = repos.createService(testApp.db, "Two", "two"); repos.setServiceEnabled(testApp.db, service.id, true); repos.setServiceEnabled(testApp.db, other.id, true); const binding = repos.insertBinding( testApp.db, domain.id, service.id, "one", null, ); const otherBinding = repos.insertBinding( testApp.db, domain.id, other.id, "two", null, ); repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, }); repos.updateBindingLbConfig(testApp.db, otherBinding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), error: null, }); const res = await testApp.inject({ method: "POST", url: `/api/v1/services/${service.id}/certificates/check`, headers, }); expect(res.statusCode).toBe(200); expect((res.json() as { checked: number }).checked).toBe(1); const certs = repos.listCertificates(testApp.db); expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true); expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false); await testApp.close(); }); });