quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m16s
quality / api (push) Successful in 58s
CD / quality (push) Successful in 2m26s
CD / publish (push) Successful in 1m40s
Legacy toggle оставлял multi-IP привязки с неполным пулом в невидимом preserved — UI терял домены и падал на дубликатах. Чиним hydrate и чиним БД при buildView. Co-authored-by: Cursor <cursoragent@cursor.com>
507 lines
16 KiB
TypeScript
507 lines
16 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
|
import { repos } from "@cfdm/db";
|
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
|
import { buildApp } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import {
|
|
listGroupViews,
|
|
toggleServiceIp,
|
|
updateConfig,
|
|
} from "../src/services/service-config-service.js";
|
|
|
|
function mockCf(): CloudflareClient {
|
|
return {
|
|
listDnsRecords: async () => [],
|
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
|
id: `cf-${payload.name}`,
|
|
type: payload.type,
|
|
name: payload.name,
|
|
content: payload.content,
|
|
ttl: 1,
|
|
proxied: false,
|
|
}),
|
|
updateDnsRecord: async (
|
|
_zoneId: string,
|
|
id: string,
|
|
payload: { type: string; name: string; content: string },
|
|
) => ({
|
|
id,
|
|
type: payload.type,
|
|
name: payload.name,
|
|
content: payload.content,
|
|
ttl: 1,
|
|
proxied: false,
|
|
}),
|
|
deleteDnsRecord: async () => undefined,
|
|
verifyToken: async () => true,
|
|
listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }],
|
|
} as unknown as CloudflareClient;
|
|
}
|
|
|
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
|
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("create service then list groups", () => {
|
|
it("accepts sqlite-shaped health fields on service config PATCH", () => {
|
|
const parsed = updateServiceConfigSchema.parse({
|
|
domains: [
|
|
{
|
|
fqdn: "gw.example.com",
|
|
target_ips: ["1.2.3.4"],
|
|
health_check_enabled: 1,
|
|
health_check_verify_tls: 0,
|
|
health_check_providers: '["local","cloudflare"]',
|
|
health_check_aggregate: "majority",
|
|
},
|
|
],
|
|
});
|
|
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
|
|
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
|
|
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
|
|
"local",
|
|
"cloudflare",
|
|
]);
|
|
});
|
|
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
const cf = mockCf();
|
|
|
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
|
const group = repos.createServiceGroup(
|
|
app.db,
|
|
"VPN",
|
|
"vpn",
|
|
null,
|
|
"vpn.example.com",
|
|
);
|
|
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/services",
|
|
headers,
|
|
payload: {
|
|
name: "Panel",
|
|
slug: "panel",
|
|
service_group_id: group.id,
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
const created = createRes.json() as {
|
|
id: number;
|
|
name: string;
|
|
enabled: boolean;
|
|
};
|
|
expect(created.id).toBeTypeOf("number");
|
|
expect(created.enabled).toBe(true);
|
|
|
|
await updateConfig(app.db, cf, created.id, {
|
|
ips: ["1.2.3.4"],
|
|
service_group_id: group.id,
|
|
domains: [
|
|
{
|
|
fqdn: "panel.example.com",
|
|
target_ips: ["1.2.3.4"],
|
|
target_ip_weights: { "1.2.3.4": 1 },
|
|
target_ip_priorities: { "1.2.3.4": 1 },
|
|
lb_mode: "round_robin",
|
|
health_check_enabled: true,
|
|
health_check_type: "tcp",
|
|
health_check_port: 443,
|
|
health_check_path: null,
|
|
health_check_expected_status: null,
|
|
health_check_interval_sec: 30,
|
|
health_check_timeout_ms: 3000,
|
|
health_check_verify_tls: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
const body = await listGroupViews(app.db);
|
|
const parsed = serviceGroupsResponseSchema.safeParse(body);
|
|
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
|
|
|
const listRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/service-groups",
|
|
headers,
|
|
});
|
|
expect(listRes.statusCode).toBe(200);
|
|
const httpParsed = serviceGroupsResponseSchema.safeParse(listRes.json());
|
|
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
|
true,
|
|
);
|
|
const listed = httpParsed.data!.groups
|
|
.find((g) => g.id === group.id)
|
|
?.services.find((s) => s.id === created.id);
|
|
expect(listed).toBeDefined();
|
|
expect(listed?.lb_mode).toBe("round_robin");
|
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("PATCH /services/:id persists health providers and aggregate", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
const cf = mockCf();
|
|
|
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/services",
|
|
headers,
|
|
payload: { name: "GW", slug: "gw" },
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
const created = createRes.json() as { id: number };
|
|
|
|
await updateConfig(app.db, cf, created.id, {
|
|
ips: ["1.2.3.4"],
|
|
domains: [
|
|
{
|
|
fqdn: "gw.example.com",
|
|
target_ips: ["1.2.3.4"],
|
|
health_check_enabled: true,
|
|
health_check_type: "tcp",
|
|
health_check_interval_sec: 30,
|
|
health_check_timeout_ms: 3000,
|
|
health_check_providers: ["local", "cloudflare"],
|
|
health_check_aggregate: "majority",
|
|
},
|
|
],
|
|
});
|
|
|
|
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
|
|
expect(stored.health_check_enabled).toBe(true);
|
|
expect(Array.isArray(stored.health_check_providers)).toBe(true);
|
|
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
|
|
expect(stored.health_check_aggregate).toBe("majority");
|
|
|
|
const getRes = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/services/${created.id}`,
|
|
headers,
|
|
});
|
|
expect(getRes.statusCode).toBe(200);
|
|
const view = getRes.json() as {
|
|
domains: Array<{
|
|
health_check_enabled: boolean;
|
|
health_check_providers: string[];
|
|
health_check_aggregate: string;
|
|
}>;
|
|
};
|
|
expect(view.domains[0]?.health_check_enabled).toBe(true);
|
|
expect(view.domains[0]?.health_check_providers).toEqual([
|
|
"local",
|
|
"cloudflare",
|
|
]);
|
|
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("POST /services returns resolved ServiceView with numeric id", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/services",
|
|
headers,
|
|
payload: { name: "Bare", slug: "bare" },
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
const created = createRes.json() as Record<string, unknown>;
|
|
expect(typeof created.id).toBe("number");
|
|
expect(created.name).toBe("Bare");
|
|
expect(created.enabled).toBe(true);
|
|
expect(Array.isArray(created.ips)).toBe(true);
|
|
expect(Array.isArray(created.domains)).toBe(true);
|
|
|
|
const listRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/service-groups",
|
|
headers,
|
|
});
|
|
expect(listRes.statusCode).toBe(200);
|
|
const parsed = serviceGroupsResponseSchema.safeParse(listRes.json());
|
|
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
|
expect(parsed.data!.ungrouped.some((s) => s.slug === "bare")).toBe(true);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
const cf = mockCf();
|
|
|
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
|
const group = repos.createServiceGroup(
|
|
app.db,
|
|
"VPN",
|
|
"vpn",
|
|
null,
|
|
"vpn.example.com",
|
|
);
|
|
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/services",
|
|
headers,
|
|
payload: {
|
|
name: "Panel",
|
|
slug: "panel-ip-toggle",
|
|
service_group_id: group.id,
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
const created = createRes.json() as { id: number };
|
|
|
|
const domainPayload = {
|
|
lb_mode: "round_robin" as const,
|
|
health_check_enabled: false,
|
|
health_check_type: "tcp" as const,
|
|
health_check_port: 443,
|
|
health_check_path: null,
|
|
health_check_expected_status: null,
|
|
health_check_interval_sec: 30,
|
|
health_check_timeout_ms: 3000,
|
|
health_check_verify_tls: false,
|
|
};
|
|
|
|
await updateConfig(app.db, cf, created.id, {
|
|
ips: ["1.2.3.4", "5.6.7.8"],
|
|
service_group_id: group.id,
|
|
domains: [
|
|
{
|
|
fqdn: "panel.example.com",
|
|
target_ips: ["1.2.3.4", "5.6.7.8"],
|
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
|
...domainPayload,
|
|
},
|
|
{
|
|
fqdn: "extra.example.com",
|
|
target_ips: ["1.2.3.4"],
|
|
target_ip_weights: { "1.2.3.4": 1 },
|
|
target_ip_priorities: { "1.2.3.4": 1 },
|
|
...domainPayload,
|
|
},
|
|
],
|
|
});
|
|
|
|
const commonBinding = repos
|
|
.listBindingsByService(app.db, created.id)
|
|
.find((b) => b.hostname === "panel")!;
|
|
const extraBinding = repos
|
|
.listBindingsByService(app.db, created.id)
|
|
.find((b) => b.hostname === "extra")!;
|
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
|
);
|
|
expect(
|
|
repos
|
|
.listRecordsForBinding(app.db, commonBinding.id)
|
|
.map((r) => r.content)
|
|
.sort(),
|
|
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
|
|
|
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
|
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
|
|
|
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
|
);
|
|
expect(
|
|
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
|
?.enabled,
|
|
).toBe(false);
|
|
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
|
);
|
|
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
|
// DNS for common FQDN drops the disabled IP only.
|
|
expect(
|
|
repos
|
|
.listRecordsForBinding(app.db, commonBinding.id)
|
|
.map((r) => r.content)
|
|
.sort(),
|
|
).toEqual(["5.6.7.8"]);
|
|
// Per-IP extra FQDN has no enabled targets → A records removed.
|
|
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
|
|
|
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
|
expect(
|
|
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
|
?.enabled,
|
|
).toBe(true);
|
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
|
);
|
|
expect(
|
|
repos
|
|
.listRecordsForBinding(app.db, commonBinding.id)
|
|
.map((r) => r.content)
|
|
.sort(),
|
|
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
|
expect(
|
|
repos
|
|
.listRecordsForBinding(app.db, extraBinding.id)
|
|
.map((r) => r.content),
|
|
).toEqual(["1.2.3.4"]);
|
|
|
|
// HTTP toggle still updates ip_enabled without mutating bindings.
|
|
repos.setServiceEnabled(app.db, created.id, false);
|
|
const offRes = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
|
headers,
|
|
payload: { ip: "1.2.3.4", enabled: false },
|
|
});
|
|
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
|
|
const offView = offRes.json() as {
|
|
ips: string[];
|
|
ip_enabled: Record<string, boolean>;
|
|
};
|
|
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
|
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", async () => {
|
|
const app = await buildApp({
|
|
config: { ...loadConfig(), staticDir: null },
|
|
memory: true,
|
|
});
|
|
const headers = await authHeaders(app);
|
|
const cf = mockCf();
|
|
|
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
|
const group = repos.createServiceGroup(
|
|
app.db,
|
|
"VPN",
|
|
"vpn-repair",
|
|
null,
|
|
"vpn.example.com",
|
|
);
|
|
|
|
const createRes = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/services",
|
|
headers,
|
|
payload: {
|
|
name: "Repair",
|
|
slug: "panel-ip-repair",
|
|
service_group_id: group.id,
|
|
},
|
|
});
|
|
expect(createRes.statusCode).toBe(200);
|
|
const created = createRes.json() as { id: number };
|
|
|
|
await updateConfig(app.db, cf, created.id, {
|
|
ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
|
service_group_id: group.id,
|
|
domains: [
|
|
{
|
|
fqdn: "gw.example.com",
|
|
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
|
lb_mode: "round_robin",
|
|
health_check_enabled: false,
|
|
health_check_type: "tcp",
|
|
health_check_port: 443,
|
|
health_check_path: null,
|
|
health_check_expected_status: null,
|
|
health_check_interval_sec: 30,
|
|
health_check_timeout_ms: 3000,
|
|
health_check_verify_tls: false,
|
|
},
|
|
{
|
|
fqdn: "extra.example.com",
|
|
target_ips: ["1.2.3.4"],
|
|
target_ip_weights: { "1.2.3.4": 1 },
|
|
target_ip_priorities: { "1.2.3.4": 1 },
|
|
lb_mode: "round_robin",
|
|
health_check_enabled: false,
|
|
health_check_type: "tcp",
|
|
health_check_port: 443,
|
|
health_check_path: null,
|
|
health_check_expected_status: null,
|
|
health_check_interval_sec: 30,
|
|
health_check_timeout_ms: 3000,
|
|
health_check_verify_tls: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
const commonBinding = repos
|
|
.listBindingsByService(app.db, created.id)
|
|
.find((b) => b.hostname === "gw")!;
|
|
const extraBinding = repos
|
|
.listBindingsByService(app.db, created.id)
|
|
.find((b) => b.hostname === "extra")!;
|
|
|
|
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
|
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
|
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
|
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
|
]);
|
|
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
|
"1.2.3.4",
|
|
"5.6.7.8",
|
|
]);
|
|
|
|
const getRes = await app.inject({
|
|
method: "GET",
|
|
url: `/api/v1/services/${created.id}`,
|
|
headers,
|
|
});
|
|
expect(getRes.statusCode).toBe(200);
|
|
const view = getRes.json() as {
|
|
domains: Array<{ fqdn: string; target_ips: string[] }>;
|
|
};
|
|
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
|
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
|
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
|
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
|
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
|
"1.2.3.4",
|
|
"5.6.7.8",
|
|
"9.9.9.9",
|
|
]);
|
|
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
|
|
|
await app.close();
|
|
});
|
|
});
|