194 lines
9.1 KiB
TypeScript
194 lines
9.1 KiB
TypeScript
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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(async () => response))
|
|
.verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
|
|
|
expect(result).toEqual([expect.objectContaining({ level: "error", code, field: "TOT Users" })]);
|
|
});
|
|
|
|
test("rejects more than the requested two bounded group results", async () => {
|
|
const response = groups("TOT Users", "Unrelated One", "Unrelated Two");
|
|
const result = await catalog(vi.fn<typeof globalThis.fetch>(async () => response))
|
|
.verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
|
|
|
expect(result).toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
|
expect(JSON.stringify(result)).not.toContain("Unrelated");
|
|
});
|
|
|
|
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<typeof globalThis.fetch>(async () => response))
|
|
.verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
|
|
|
expect(result).toEqual([expect.objectContaining({ code: "oidc_mapped_group_ambiguous", field: "TOT Users" })]);
|
|
});
|
|
|
|
test.each([
|
|
["missing next", { pagination: {}, results: [{ name: "TOT Users" }] }],
|
|
["numeric next", { pagination: { next: 2 }, results: [{ name: "TOT Users" }] }],
|
|
["boolean next", { pagination: { next: false }, results: [{ name: "TOT Users" }] }],
|
|
["malformed continuation", { pagination: { next: "not a URL" }, results: [{ name: "TOT Users" }] }],
|
|
])("rejects a group response with %s as unreachable", async (_label, body) => {
|
|
const result = await catalog(vi.fn<typeof globalThis.fetch>(async () => Response.json(body)))
|
|
.verifyConfiguredGroups(["TOT Users"], new AbortController().signal);
|
|
|
|
expect(result).toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
|
expect(result).not.toContainEqual(expect.objectContaining({ code: "oidc_mapped_group_ambiguous" }));
|
|
});
|
|
|
|
test("enforces the exact Authentik token boundary before making a request", async () => {
|
|
const fetch = vi.fn<typeof globalThis.fetch>(async () => groups("TOT Users"));
|
|
const accepted = createAuthentikGroupCatalog({
|
|
baseUrl: "https://authentik.example.test", apiToken: "a".repeat(16 * 1024), fetch,
|
|
});
|
|
const rejected = createAuthentikGroupCatalog({
|
|
baseUrl: "https://authentik.example.test", apiToken: "a".repeat(16 * 1024 + 1), fetch,
|
|
});
|
|
|
|
await expect(accepted.verifyConfiguredGroups(["TOT Users"], new AbortController().signal)).resolves.toEqual([]);
|
|
await expect(rejected.verifyConfiguredGroups(["TOT Users"], new AbortController().signal))
|
|
.resolves.toEqual([expect.objectContaining({ code: "oidc_group_catalog_unreachable" })]);
|
|
expect(fetch).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<void>(() => { /* cancellation remains advisory */ });
|
|
},
|
|
});
|
|
const completion = catalog(vi.fn<typeof globalThis.fetch>(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<typeof globalThis.fetch>(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<typeof globalThis.fetch>((_input, init) => new Promise<Response>((_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<typeof globalThis.fetch>(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);
|
|
});
|