diff --git a/src/integration.test.ts b/src/integration.test.ts new file mode 100644 index 0000000..4aebb4b --- /dev/null +++ b/src/integration.test.ts @@ -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; +} + +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 => { + 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"); +});