import { expect, test, vi } from "vitest"; import { createAuthentikGroupCatalog } from "../src/auth/authentik-group-catalog.js"; const apiToken = "authentik-api-token-UNIQUE-9Q7"; const clientSecret = "oidc-client-secret-UNIQUE-7P3"; const passwordHash = "$argon2id$v=19$password-hash-UNIQUE-2T8"; const cookie = "cookie-UNIQUE-5M1"; const filePath = "/private/path-UNIQUE-4K6"; function catalog(fetch: typeof globalThis.fetch) { return createAuthentikGroupCatalog({ baseUrl: "https://authentik.example.test", apiToken, fetch, }); } function groups(...names: string[]): Response { return Response.json({ pagination: { next: null }, results: names.map((name) => ({ name })) }); } test("looks up only each configured group by its exact encoded name", async () => { const fetch = vi.fn(async () => groups("TOT Users")); const result = await catalog(fetch).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); expect(result).toEqual([]); expect(fetch).toHaveBeenCalledOnce(); const [input, init] = fetch.mock.calls[0]!; expect(String(input)).toBe("https://authentik.example.test/api/v3/core/groups/?name=TOT+Users&include_users=false&page_size=2"); expect(init).toMatchObject({ redirect: "error" }); expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${apiToken}`); expect(init?.signal).toBeInstanceOf(AbortSignal); }); test.each([ ["missing", groups(), "oidc_mapped_group_missing"], ["duplicate exact results", groups("TOT Users", "TOT Users"), "oidc_mapped_group_ambiguous"], ["non-exact result", groups("tot users"), "oidc_mapped_group_missing"], ])("reports configured group %s without exposing an upstream body", async (_caseName, response, code) => { const result = await catalog(vi.fn(async () => response)) .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); expect(result).toEqual([expect.objectContaining({ level: "error", code, field: "TOT Users" })]); }); test("treats a pagination continuation as an ambiguous configured group", async () => { const response = Response.json({ pagination: { next: "https://authentik.example.test/api/v3/core/groups/?page=2" }, results: [{ name: "TOT Users" }] }); const result = await catalog(vi.fn(async () => response)) .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); expect(result).toEqual([expect.objectContaining({ code: "oidc_mapped_group_ambiguous", field: "TOT Users" })]); }); test.each([ ["unauthorized", new Response("upstream body must not escape", { status: 401 }), "oidc_group_catalog_unauthorized"], ["forbidden", new Response("upstream body must not escape", { status: 403 }), "oidc_group_catalog_unauthorized"], ["redirect", new Response(null, { status: 302, headers: { location: "https://elsewhere.invalid" } }), "oidc_group_catalog_unreachable"], ["invalid json", new Response("not-json"), "oidc_group_catalog_unreachable"], ])("returns a stable diagnostic for %s without parsing or leaking response data", async (_caseName, response, code) => { const result = await catalog(vi.fn(async () => response)) .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); expect(result).toEqual([expect.objectContaining({ level: "error", code })]); expect(JSON.stringify(result)).not.toContain("upstream body must not escape"); }); test("refuses declared and streamed group catalog bodies larger than one MiB", async () => { const declared = new Response(new ReadableStream({ pull() { throw new Error("must not read"); } }), { headers: { "content-length": String(1024 * 1024 + 1) }, }); const streamed = new Response(new ReadableStream({ type: "bytes", pull(controller) { controller.enqueue(new Uint8Array(1024 * 1024)); controller.enqueue(new Uint8Array(1)); controller.close(); }, })); for (const response of [declared, streamed]) { const result = await catalog(vi.fn(async () => response)) .verifyConfiguredGroups(["TOT Users"], new AbortController().signal); expect(result).toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); } }); test.each([ ["malformed", "not-a-number"], ["oversized", String(1024 * 1024 + 1)], ])("cancels a %s declared-size body without waiting for hanging cancellation", async (_caseName, contentLength) => { let cancelled = false; const body = new ReadableStream({ pull() { /* early declared-size rejection must not read */ }, cancel() { cancelled = true; return new Promise(() => { /* cancellation remains advisory */ }); }, }); const completion = catalog(vi.fn(async () => new Response(body, { headers: { "content-length": contentLength }, }))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); await expect(Promise.race([ completion, new Promise((resolve) => setTimeout(() => resolve("timed-out"), 100)), ])).resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("contains a rejected declared-size cancellation without an unhandled rejection", async () => { let cancelled = false; const body = new ReadableStream({ pull() { /* early declared-size rejection must not read */ }, cancel() { cancelled = true; return Promise.reject(new Error("cancellation-detail-must-stay-contained")); }, }); await expect(catalog(vi.fn(async () => new Response(body, { headers: { "content-length": "invalid" }, }))).verifyConfiguredGroups(["TOT Users"], new AbortController().signal)) .resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); expect(cancelled).toBe(true); }); test("aborts a hanging request at five seconds", async () => { vi.useFakeTimers(); try { let aborted = false; const fetch = vi.fn((_input, init) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => { aborted = true; reject(new DOMException("aborted", "AbortError")); }, { once: true }); })); const completion = catalog(fetch).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); await vi.advanceTimersByTimeAsync(5_000); await expect(completion).resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]); expect(aborted).toBe(true); } finally { vi.useRealTimers(); } }); test("never puts secrets or upstream details in catalog diagnostics", async () => { const upstreamDetail = `${apiToken} ${clientSecret} ${passwordHash} ${cookie} ${filePath}`; const result = await catalog(vi.fn(async () => { throw new Error(upstreamDetail); })).verifyConfiguredGroups(["TOT Users"], new AbortController().signal); const rendered = JSON.stringify(result); for (const sentinel of [apiToken, clientSecret, passwordHash, cookie, filePath]) expect(rendered).not.toContain(sentinel); });