import { describe, expect, it } from "vitest"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { createMemoryDb, repos, runMigrations } from "@cfdm/db"; 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("service groups health enrichment", () => { it("GET /service-groups returns health_status from ip_health_status", async () => { const app = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(app); const domain = repos.createDomain(app.db, null, "example.com", "zone-1"); const group = repos.createServiceGroup( app.db, "VPN", "vpn", null, "vpn.example.com", ); repos.updateServiceGroup(app.db, group.id, "VPN", "vpn", null, "vpn.example.com", { health_check_enabled: true, health_check_type: "tcp", health_check_port: 443, }); const service = repos.createService(app.db, "Panel", "panel"); repos.setServiceGroup(app.db, service.id, group.id); repos.setServiceEnabled(app.db, service.id, true); repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]); const binding = repos.insertBinding( app.db, domain.id, service.id, "panel", null, ); repos.replaceBindingIpsWithMeta(app.db, binding.id, [ { ip: "1.2.3.4", weight: 1, priority: 1 }, ]); repos.upsertIpHealthStatus( app.db, "binding", binding.id, "1.2.3.4", "degraded", 120, 1, null, ); repos.upsertIpHealthStatus( app.db, "group", group.id, "1.2.3.4", "up", 40, 0, null, ); const res = await app.inject({ method: "GET", url: "/api/v1/service-groups", headers, }); expect(res.statusCode).toBe(200); const body = res.json() as { groups: Array<{ id: number; health_status: string; health_latency_ms: number | null; services: Array<{ id: number; health_status: string; health_latency_ms: number | null; ip_health: Array<{ ip: string; status: string; latency_ms: number | null; }>; }>; }>; }; const groupView = body.groups.find((g) => g.id === group.id); expect(groupView).toBeDefined(); expect(groupView!.services[0]?.health_status).toBe("degraded"); expect(groupView!.services[0]?.health_latency_ms).toBe(120); expect(groupView!.services[0]?.ip_health).toEqual([ { ip: "1.2.3.4", status: "degraded", latency_ms: 120, last_checked_at: expect.any(String), last_error: null, provider: "local", colo: null, }, ]); // group worst = degraded (from service) over up (group scope) expect(groupView!.health_status).toBe("degraded"); await app.close(); }); it("group badge ignores disabled service health and pool-only dead IPs", async () => { const app = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, }); const headers = await authHeaders(app); const domain = repos.createDomain(app.db, null, "rkns.top", "zone-tg"); const group = repos.createServiceGroup( app.db, "TG Proxy", "custom", null, "gt.rkns.top", ); repos.updateServiceGroup(app.db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", { health_check_enabled: true, health_check_type: "tcp", health_check_port: 443, }); const live = repos.createService(app.db, "Live", "live"); repos.setServiceGroup(app.db, live.id, group.id); repos.setServiceEnabled(app.db, live.id, true); repos.replaceServiceIps(app.db, live.id, ["10.0.0.1", "10.0.0.99"]); const liveBinding = repos.insertBinding(app.db, domain.id, live.id, "gt", null); repos.replaceBindingIpsWithMeta(app.db, liveBinding.id, [ { ip: "10.0.0.1", weight: 1, priority: 1 }, ]); const dead = repos.createService(app.db, "Dead", "dead"); repos.setServiceGroup(app.db, dead.id, group.id); repos.setServiceEnabled(app.db, dead.id, false); const deadBinding = repos.insertBinding(app.db, domain.id, dead.id, "old", null); repos.replaceBindingIpsWithMeta(app.db, deadBinding.id, [ { ip: "10.0.0.2", weight: 1, priority: 1 }, ]); // Live binding + group FQDN are up; pool-only IP and disabled service are down. repos.upsertIpHealthStatus(app.db, "binding", liveBinding.id, "10.0.0.1", "up", 20, 0, null); repos.upsertIpHealthStatus(app.db, "group", group.id, "10.0.0.1", "up", 20, 0, null); repos.upsertIpHealthStatus(app.db, "group", group.id, "10.0.0.99", "down", null, 5, "timeout"); repos.upsertIpHealthStatus(app.db, "binding", deadBinding.id, "10.0.0.2", "down", null, 5, "timeout"); const res = await app.inject({ method: "GET", url: "/api/v1/service-groups", headers, }); expect(res.statusCode).toBe(200); const body = res.json() as { groups: Array<{ id: number; health_status: string }>; }; const groupView = body.groups.find((g) => g.id === group.id); expect(groupView?.health_status).toBe("up"); await app.close(); }); }); describe("group health check targets", () => { it("probes binding IPs for group scope, not unused pool IPs", () => { const { db, sqlite } = createMemoryDb(); runMigrations(sqlite); const domain = repos.createDomain(db, null, "rkns.top", "zone-1"); const group = repos.createServiceGroup(db, "TG Proxy", "custom", null, "gt.rkns.top"); repos.updateServiceGroup(db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", { health_check_enabled: true, health_check_type: "tcp", health_check_port: 443, }); const service = repos.createService(db, "Svc", "svc"); repos.setServiceGroup(db, service.id, group.id); repos.setServiceEnabled(db, service.id, true); repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.99"]); const binding = repos.insertBinding(db, domain.id, service.id, "gt", null); repos.replaceBindingIpsWithMeta(db, binding.id, [ { ip: "10.0.0.1", weight: 1, priority: 1 }, ]); const targets = repos.listHealthCheckTargets(db); const groupScope = targets.filter((t) => t.scope === "group" && t.ref_id === group.id); expect(groupScope.map((t) => t.ip).sort()).toEqual(["10.0.0.1"]); expect(groupScope.every((t) => t.hostname === "gt.rkns.top")).toBe(true); }); it("pruneStaleIpHealthStatus drops pool-only and orphan group rows", () => { const { db, sqlite } = createMemoryDb(); runMigrations(sqlite); const domain = repos.createDomain(db, null, "rkns.top", "zone-1"); const group = repos.createServiceGroup(db, "TG Proxy", "custom", null, "gt.rkns.top"); repos.updateServiceGroup(db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", { health_check_enabled: true, health_check_type: "tcp", health_check_port: 443, }); const service = repos.createService(db, "Svc", "svc"); repos.setServiceGroup(db, service.id, group.id); repos.setServiceEnabled(db, service.id, true); repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.99"]); const binding = repos.insertBinding(db, domain.id, service.id, "gt", null); repos.replaceBindingIpsWithMeta(db, binding.id, [ { ip: "10.0.0.1", weight: 1, priority: 1 }, ]); repos.upsertIpHealthStatus(db, "group", group.id, "10.0.0.1", "up", 10, 0, null); repos.upsertIpHealthStatus(db, "group", group.id, "10.0.0.99", "down", null, 3, "dead pool"); repos.upsertIpHealthStatus(db, "group", 999, "1.1.1.1", "down", null, 3, "orphan ref"); const targets = repos.listHealthCheckTargets(db); const deleted = repos.pruneStaleIpHealthStatus(db, targets); expect(deleted).toBeGreaterThanOrEqual(2); const rows = repos.listIpHealthStatus(db, "group", group.id); expect(rows.map((r) => r.ip)).toEqual(["10.0.0.1"]); expect(repos.listIpHealthStatus(db, "group", 999)).toEqual([]); }); });