import { describe, it, expect, vi, afterEach } from "vitest"; import { createIntrospector } from "../src/auth/introspection.js"; import type { IntrospectionDeps } from "../src/auth/introspection.js"; function mockFetch(status: number, body: unknown) { return vi.fn().mockResolvedValue( new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }), ); } const baseDeps: IntrospectionDeps = { issuer: "https://id-customers.siax.io", clientId: "cid", clientSecret: "csec", }; afterEach(() => { vi.restoreAllMocks(); }); describe("createIntrospector", () => { it("accepts an active token", async () => { const fetchImpl = mockFetch(200, { active: true, sub: "u1", aud: ["cid"] }); const introspect = createIntrospector({ ...baseDeps, fetchImpl }); const r = await introspect("tok"); expect(r.active).toBe(true); expect(r.sub).toBe("u1"); expect(fetchImpl).toHaveBeenCalledWith( "https://id-customers.siax.io/oauth/v2/introspect", expect.objectContaining({ method: "POST" }), ); }); it("rejects an inactive token (fail-closed)", async () => { const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(200, { active: false }) }); const r = await introspect("tok"); expect(r.active).toBe(false); expect(r.reason).toBe("token_inactive"); }); it("fails closed on non-200 (Zitadel returns 400 on bad client auth)", async () => { const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(400, {}) }); const r = await introspect("tok"); expect(r.active).toBe(false); expect(r.reason).toBe("introspection_http_400"); }); it("fails closed when introspection is unreachable", async () => { const fetchImpl = vi.fn().mockRejectedValue(new Error("fetch failed")); const introspect = createIntrospector({ ...baseDeps, fetchImpl }); const r = await introspect("tok"); expect(r.active).toBe(false); expect(r.reason).toBe("introspection_unreachable"); }); it("fails closed on invalid JSON", async () => { const fetchImpl = vi.fn().mockResolvedValue( new Response("not json", { status: 200 }), ); const introspect = createIntrospector({ ...baseDeps, fetchImpl }); const r = await introspect("tok"); expect(r.active).toBe(false); expect(r.reason).toBe("introspection_invalid_response"); }); it("accepts a token without aud (RFC 7662)", async () => { const introspect = createIntrospector({ ...baseDeps, expectedAudience: "proj-1", fetchImpl: mockFetch(200, { active: true, sub: "u1" }), }); const r = await introspect("tok"); expect(r.active).toBe(true); }); it("enforces asserted aud against expected audience", async () => { const mismatch = createIntrospector({ ...baseDeps, expectedAudience: "proj-1", fetchImpl: mockFetch(200, { active: true, aud: ["other", "cid"] }), }); const r = await mismatch("tok"); expect(r.active).toBe(false); expect(r.reason).toBe("audience_mismatch"); const match = createIntrospector({ ...baseDeps, expectedAudience: "proj-1", fetchImpl: mockFetch(200, { active: true, aud: ["cid", "proj-1"] }), }); const r2 = await match("tok"); expect(r2.active).toBe(true); }); });