test: add integration tests for reconcile HTTP endpoint

This commit is contained in:
Prox 2026-03-04 00:28:51 +02:00
parent 880cb2de4b
commit cd6e8ea120

225
src/integration.test.ts Normal file
View File

@ -0,0 +1,225 @@
import { assertEquals, assertExists } from "@std/assert";
import { createHandler } from "./server.ts";
import { NetbirdClient } from "./netbird/client.ts";
import type { GiteaClient } from "./gitea/client.ts";
import type { Config } from "./config.ts";
// -----------------------------------------------------------------------------
// Mock NetBird API
// -----------------------------------------------------------------------------
interface ApiCall {
method: string;
path: string;
body?: Record<string, unknown>;
}
const MOCK_BASE = "https://nb.test/api";
const TEST_KEY_VALUE = "NBSK-test-key-value-abc123";
function createMockFetch(calls: ApiCall[]) {
let groupCounter = 0;
return async (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
const method = init?.method ?? "GET";
const path = url.replace(MOCK_BASE, "");
const body = init?.body ? JSON.parse(init.body as string) : undefined;
calls.push({ method, path, body });
// Route GET list endpoints — return empty arrays
if (method === "GET") {
return Response.json([]);
}
// POST /groups — return a created group
if (method === "POST" && path === "/groups") {
groupCounter++;
return Response.json({
id: `mock-group-${groupCounter}`,
name: body.name,
peers_count: 0,
peers: [],
issued: "api",
});
}
// POST /setup-keys — return a created key
if (method === "POST" && path === "/setup-keys") {
return Response.json({
id: 1,
name: body.name,
type: body.type,
key: TEST_KEY_VALUE,
expires: "2027-01-01T00:00:00Z",
valid: true,
revoked: false,
used_times: 0,
state: "valid",
auto_groups: body.auto_groups ?? [],
usage_limit: body.usage_limit ?? 0,
});
}
// POST /policies — return a created policy
if (method === "POST" && path === "/policies") {
return Response.json({
id: "mock-policy-1",
name: body.name,
description: body.description ?? "",
enabled: body.enabled ?? true,
rules: body.rules ?? [],
});
}
// DELETE — 204 No Content
if (method === "DELETE") {
return new Response(null, { status: 204 });
}
return Response.json({ error: "mock: unhandled route" }, { status: 500 });
};
}
// -----------------------------------------------------------------------------
// Test fixtures
// -----------------------------------------------------------------------------
const MOCK_CONFIG: Config = {
netbirdApiUrl: MOCK_BASE,
netbirdApiToken: "nb-test-token",
giteaUrl: "https://gitea.test",
giteaToken: "gitea-test-token",
giteaRepo: "org/repo",
reconcilerToken: "secret",
pollIntervalSeconds: 30,
port: 8080,
dataDir: "/data",
};
/** Desired state with one group and one setup key referencing it. */
const DESIRED_STATE = {
groups: {
pilots: { peers: [] },
},
setup_keys: {
"Pilot-hawk-72": {
type: "one-off",
expires_in: 604800,
usage_limit: 1,
auto_groups: ["pilots"],
enrolled: false,
},
},
};
function buildHandler(calls: ApiCall[]) {
const mockFetch = createMockFetch(calls);
const netbird = new NetbirdClient(MOCK_BASE, "nb-test-token", mockFetch);
// The GiteaClient is not exercised in reconcile tests — stub it out.
const gitea = {} as GiteaClient;
return createHandler({
config: MOCK_CONFIG,
netbird,
gitea,
reconcileInProgress: { value: false },
});
}
function authedRequest(path: string, body?: unknown): Request {
return new Request(`http://localhost:8080${path}`, {
method: "POST",
headers: {
"Authorization": "Bearer secret",
"Content-Type": "application/json",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
Deno.test("POST /reconcile?dry_run=true returns planned operations", async () => {
const calls: ApiCall[] = [];
const handler = buildHandler(calls);
const resp = await handler(
authedRequest("/reconcile?dry_run=true", DESIRED_STATE),
);
assertEquals(resp.status, 200);
const json = await resp.json();
assertEquals(json.status, "planned");
const opTypes = json.operations.map((op: { type: string }) => op.type);
assertEquals(opTypes.includes("create_group"), true);
assertEquals(opTypes.includes("create_setup_key"), true);
});
Deno.test("POST /reconcile apply creates resources and returns keys", async () => {
const calls: ApiCall[] = [];
const handler = buildHandler(calls);
const resp = await handler(authedRequest("/reconcile", DESIRED_STATE));
assertEquals(resp.status, 200);
const json = await resp.json();
assertEquals(json.status, "applied");
// The created setup key's raw value should be in the response
assertExists(json.created_keys["Pilot-hawk-72"]);
assertEquals(json.created_keys["Pilot-hawk-72"], TEST_KEY_VALUE);
// Verify mock API received the expected POST calls
const postGroups = calls.filter(
(c) => c.method === "POST" && c.path === "/groups",
);
assertEquals(postGroups.length, 1);
assertEquals(postGroups[0].body?.name, "pilots");
const postKeys = calls.filter(
(c) => c.method === "POST" && c.path === "/setup-keys",
);
assertEquals(postKeys.length, 1);
assertEquals(postKeys[0].body?.name, "Pilot-hawk-72");
});
Deno.test("POST /reconcile rejects unauthorized requests", async () => {
const calls: ApiCall[] = [];
const handler = buildHandler(calls);
const req = new Request("http://localhost:8080/reconcile", {
method: "POST",
headers: {
"Authorization": "Bearer wrong-token",
"Content-Type": "application/json",
},
body: JSON.stringify(DESIRED_STATE),
});
const resp = await handler(req);
assertEquals(resp.status, 401);
const json = await resp.json();
assertEquals(json.error, "unauthorized");
});
Deno.test("GET /health returns ok", async () => {
const calls: ApiCall[] = [];
const handler = buildHandler(calls);
const req = new Request("http://localhost:8080/health", { method: "GET" });
const resp = await handler(req);
assertEquals(resp.status, 200);
const json = await resp.json();
assertEquals(json.status, "ok");
});