import { describe, it, expect, vi } from "vitest"; import Fastify from "fastify"; import { envSchema } from "@siax/c0py-config"; import { createIntrospector } from "../src/auth/introspection.js"; import type { IntrospectionDeps } from "../src/auth/introspection.js"; const env = envSchema.parse({ CL0UD_BASE_URL: "https://cl0ud.siax.io", ZITADEL_ISSUER: "https://id-customers.siax.io", ZITADEL_AUDIENCE: "c0py-api.siax.io", DATABASE_URL: "postgresql://localhost:5432/c0py", ZITADEL_INTROSPECTION_CLIENT_ID: "cid", ZITADEL_INTROSPECTION_CLIENT_SECRET: "csec", ZITADEL_EXPECTED_AUDIENCE: "proj-1", NODE_ENV: "test", }); function mockIntrospect(body: unknown) { return vi.fn().mockResolvedValue(body); } function buildApp(introspectImpl: (token: string) => Promise) { const server = Fastify({ logger: false }); server.get("/health", async () => ({ status: "ok" })); server.addHook("preHandler", async (request, reply) => { if (request.url === "/health") return; const auth = request.headers.authorization; if (!auth || !auth.startsWith("Bearer ")) { await reply.code(401).send({ error: "Unauthorized" }); return; } const result = (await introspectImpl(auth.slice("Bearer ".length).trim())) as { active: boolean; sub?: string; }; if (!result.active) { await reply.code(401).send({ error: "Unauthorized" }); return; } (request as never as { user?: { sub?: string } }).user = { sub: result.sub }; }); server.get("/v1/c0py/registries", async (request) => ({ user: (request as never as { user?: { sub?: string } }).user, })); return server; } describe("preHandler auth (fail-closed)", () => { it("401 without Bearer", async () => { const app = buildApp(mockIntrospect({ active: true })); const res = await app.inject({ method: "GET", url: "/v1/c0py/registries" }); expect(res.statusCode).toBe(401); }); it("exempts /health", async () => { const app = buildApp(mockIntrospect({ active: false })); const res = await app.inject({ method: "GET", url: "/health" }); expect(res.statusCode).toBe(200); }); it("401 when introspection denies", async () => { const app = buildApp(mockIntrospect({ active: false, reason: "audience_mismatch" })); const res = await app.inject({ method: "GET", url: "/v1/c0py/registries", headers: { Authorization: "Bearer tok" }, }); expect(res.statusCode).toBe(401); }); it("200 with valid token and sub propagated", async () => { const app = buildApp(mockIntrospect({ active: true, sub: "svc-user" })); const res = await app.inject({ method: "GET", url: "/v1/c0py/registries", headers: { Authorization: "Bearer tok" }, }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ user: { sub: "svc-user" } }); }); it("env schema accepts new introspection vars", () => { expect(env.ZITADEL_INTROSPECTION_CLIENT_ID).toBe("cid"); expect(env.ZITADEL_EXPECTED_AUDIENCE).toBe("proj-1"); }); it("createIntrospector is importable and typed", async () => { expect(typeof createIntrospector).toBe("function"); }); });