feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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<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("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);
|
||||
repos.insertBinding(testApp.db, domain.id, service.id, "api", null);
|
||||
|
||||
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 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 apex is monitored without bindings", 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",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
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("skipped apex 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",
|
||||
);
|
||||
repos.upsertCertificateCheck(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
domain.zone_name,
|
||||
null,
|
||||
CERT_ERROR,
|
||||
"stale",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
CERT_MONITOR_SKIPPED,
|
||||
);
|
||||
|
||||
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",
|
||||
);
|
||||
repos.updateDomain(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
null,
|
||||
"active",
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_CONFLICT, SYNC_ERROR, SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { pullSync } from "../src/services/sync-service.js";
|
||||
|
||||
const ZONE = "rkns.top";
|
||||
const CF_ZONE_ID = "zone-rkns";
|
||||
|
||||
function mockCf(remote: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}>): CloudflareClient {
|
||||
return {
|
||||
listDnsRecords: async () =>
|
||||
remote.map((record) => ({
|
||||
...record,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
})),
|
||||
} as unknown as CloudflareClient;
|
||||
}
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("pullSync", () => {
|
||||
it("reconciles short local names with Cloudflare FQDNs", async () => {
|
||||
const db = setupDb();
|
||||
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
|
||||
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"de",
|
||||
"193.233.134.103",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_CONFLICT,
|
||||
"local",
|
||||
"cf-de",
|
||||
);
|
||||
|
||||
const cf = mockCf([
|
||||
{
|
||||
id: "cf-de",
|
||||
type: "A",
|
||||
name: "de.rkns.top",
|
||||
content: "193.233.134.103",
|
||||
},
|
||||
]);
|
||||
|
||||
const changes = await pullSync(db, cf, domain);
|
||||
expect(changes).toBeGreaterThan(0);
|
||||
|
||||
const records = repos.listDnsByDomain(db, domain.id);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].name).toBe("de.rkns.top");
|
||||
expect(records[0].sync_status).toBe(SYNC_SYNCED);
|
||||
});
|
||||
|
||||
it("marks local record as conflict when Cloudflare has another type", async () => {
|
||||
const db = setupDb();
|
||||
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
|
||||
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"mhome",
|
||||
"185.244.181.61",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_ERROR,
|
||||
"local",
|
||||
null,
|
||||
);
|
||||
|
||||
const cf = mockCf([
|
||||
{
|
||||
id: "cf-mhome",
|
||||
type: "CNAME",
|
||||
name: "mhome.rkns.top",
|
||||
content: "mmsk.rkns.top",
|
||||
},
|
||||
]);
|
||||
|
||||
await pullSync(db, cf, domain);
|
||||
|
||||
const records = repos.listDnsByDomain(db, domain.id);
|
||||
const localA = records.find((r) => r.record_type === "A");
|
||||
expect(localA?.sync_status).toBe(SYNC_CONFLICT);
|
||||
expect(localA?.last_error).toBe("type mismatch with cloudflare");
|
||||
|
||||
const localCname = records.find((r) => r.record_type === "CNAME");
|
||||
expect(localCname?.sync_status).toBe(SYNC_SYNCED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
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("services reorder", () => {
|
||||
it("PATCH /services/reorder persists order within group and ungrouped", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
|
||||
const group = repos.createServiceGroup(app.db, "VPN", "vpn", null, null);
|
||||
const alpha = repos.createService(app.db, "Alpha", "alpha");
|
||||
const beta = repos.createService(app.db, "Beta", "beta");
|
||||
const gamma = repos.createService(app.db, "Gamma", "gamma");
|
||||
|
||||
repos.setServiceGroup(app.db, alpha.id, group.id);
|
||||
repos.setServiceGroup(app.db, beta.id, group.id);
|
||||
|
||||
const reorderRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/services/reorder",
|
||||
headers,
|
||||
payload: {
|
||||
group_id: group.id,
|
||||
service_ids: [beta.id, alpha.id],
|
||||
},
|
||||
});
|
||||
expect(reorderRes.statusCode).toBe(200);
|
||||
|
||||
const grouped = repos.listServicesByGroup(app.db, group.id);
|
||||
expect(grouped.map((s) => s.id)).toEqual([beta.id, alpha.id]);
|
||||
|
||||
const ungroupedReorderRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/api/v1/services/reorder",
|
||||
headers,
|
||||
payload: {
|
||||
group_id: null,
|
||||
service_ids: [gamma.id],
|
||||
},
|
||||
});
|
||||
expect(ungroupedReorderRes.statusCode).toBe(200);
|
||||
|
||||
const ungrouped = repos.listUngroupedServices(app.db);
|
||||
expect(ungrouped[0]?.id).toBe(gamma.id);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repos } from "@cfdm/db";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
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("subdomains", () => {
|
||||
it("PATCH /subdomains/:id toggles enabled and renames", 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",
|
||||
"cf-zone-1",
|
||||
);
|
||||
const created = repos.createSubdomain(
|
||||
app.db,
|
||||
domain.id,
|
||||
"www",
|
||||
"www.example.com",
|
||||
);
|
||||
expect(created.enabled).toBe(true);
|
||||
|
||||
const disableRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/subdomains/${created.id}`,
|
||||
headers,
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(disableRes.statusCode).toBe(200);
|
||||
expect(disableRes.json().enabled).toBe(false);
|
||||
|
||||
const renameRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/subdomains/${created.id}`,
|
||||
headers,
|
||||
payload: { name: "api" },
|
||||
});
|
||||
expect(renameRes.statusCode).toBe(200);
|
||||
const renamed = renameRes.json();
|
||||
expect(renamed.name).toBe("api");
|
||||
expect(renamed.fqdn).toBe("api.example.com");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user