169 lines
5.3 KiB
TypeScript
169 lines
5.3 KiB
TypeScript
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" });
|
|
});
|
|
});
|