import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { resetServiceTokenCache, serviceAuthHeader, serviceToken, } from "./service-token"; const ISSUER = "https://auth.breakpilot.com/realms/breakpilot-dev"; function tokenResponse(value: string, expiresIn = 300) { return { ok: true, status: 200, json: async () => ({ access_token: value, expires_in: expiresIn }), } as Response; } function configure() { process.env.KEYCLOAK_ISSUER = ISSUER; process.env.PORTAL_SVC_CLIENT_ID = "portal-svc"; process.env.PORTAL_SVC_CLIENT_SECRET = "shh"; } beforeEach(() => { resetServiceTokenCache(); delete process.env.PORTAL_SVC_CLIENT_ID; delete process.env.PORTAL_SVC_CLIENT_SECRET; delete process.env.KEYCLOAK_ISSUER; }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); describe("serviceToken", () => { test("returns null and makes no request when unconfigured", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); expect(await serviceToken()).toBeNull(); expect(await serviceAuthHeader()).toEqual({}); expect(fetchSpy).not.toHaveBeenCalled(); }); test("requests a client_credentials token against the realm", async () => { configure(); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue(tokenResponse("tok-1")); expect(await serviceToken()).toBe("tok-1"); const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; expect(url).toBe(`${ISSUER}/protocol/openid-connect/token`); expect(init.method).toBe("POST"); const body = new URLSearchParams(init.body as string); expect(body.get("grant_type")).toBe("client_credentials"); expect(body.get("client_id")).toBe("portal-svc"); expect(body.get("client_secret")).toBe("shh"); }); test("caches the token across calls", async () => { configure(); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue(tokenResponse("tok-1")); await serviceToken(); await serviceToken(); await serviceToken(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); test("de-dupes concurrent fetches into one request", async () => { configure(); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValue(tokenResponse("tok-1")); const results = await Promise.all([ serviceToken(), serviceToken(), serviceToken(), ]); expect(results).toEqual(["tok-1", "tok-1", "tok-1"]); expect(fetchSpy).toHaveBeenCalledTimes(1); }); test("refreshes shortly before expiry", async () => { configure(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-25T10:00:00Z")); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(tokenResponse("tok-1", 300)) .mockResolvedValueOnce(tokenResponse("tok-2", 300)); expect(await serviceToken()).toBe("tok-1"); // 4 minutes in: still inside the window vi.setSystemTime(new Date("2026-08-25T10:04:00Z")); expect(await serviceToken()).toBe("tok-1"); // 4:40 — inside the 30s refresh margin vi.setSystemTime(new Date("2026-08-25T10:04:40Z")); expect(await serviceToken()).toBe("tok-2"); expect(fetchSpy).toHaveBeenCalledTimes(2); }); test("surfaces a rejected token request instead of calling unauthenticated", async () => { configure(); // no json() on the mock: fetchToken throws on !ok before reading the body vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 401, } as Response); await expect(serviceToken()).rejects.toThrow("service token request failed: 401"); }); test("surfaces a malformed token response", async () => { configure(); vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, json: async () => ({}), } as Response); await expect(serviceToken()).rejects.toThrow("no access_token"); }); test("a failed fetch does not poison the cache", async () => { configure(); vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce({ ok: false, status: 503 } as Response) .mockResolvedValueOnce(tokenResponse("tok-ok")); await expect(serviceToken()).rejects.toThrow(); expect(await serviceToken()).toBe("tok-ok"); }); test("a response without expires_in gets the 300s default lifetime", async () => { configure(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-30T10:00:00Z")); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access_token: "tok-default" }), } as Response) .mockResolvedValueOnce(tokenResponse("tok-next")); expect(await serviceToken()).toBe("tok-default"); // 4 minutes in: still inside the defaulted 300s window vi.setSystemTime(new Date("2026-08-30T10:04:00Z")); expect(await serviceToken()).toBe("tok-default"); // past the default expiry vi.setSystemTime(new Date("2026-08-30T10:05:01Z")); expect(await serviceToken()).toBe("tok-next"); expect(fetchSpy).toHaveBeenCalledTimes(2); }); test("serviceAuthHeader carries the bearer token when configured", async () => { configure(); vi.spyOn(globalThis, "fetch").mockResolvedValue(tokenResponse("tok-1")); expect(await serviceAuthHeader()).toEqual({ authorization: "Bearer tok-1" }); }); });