import { describe, expect, it, vi } from "vitest"; import { createMs365TokenProvider, Ms365TokenError } from "./ms365-oauth"; const CONFIG = { tenantId: "wabenhain.onmicrosoft.com", clientId: "11111111-2222-3333-4444-555555555555", clientSecret: "s3cr3t~value" }; function tokenResponse(token: string, expiresIn = 3600): Response { return new Response(JSON.stringify({ token_type: "Bearer", expires_in: expiresIn, access_token: token }), { status: 200 }); } describe("createMs365TokenProvider", () => { it("requests a client-credentials token for Exchange Online", async () => { const fetchImpl = vi.fn(async () => tokenResponse("tok-1")); const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); await expect(provider.getToken()).resolves.toBe("tok-1"); const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe("https://login.microsoftonline.com/wabenhain.onmicrosoft.com/oauth2/v2.0/token"); expect(init.method).toBe("POST"); const body = new URLSearchParams(String(init.body)); expect(body.get("grant_type")).toBe("client_credentials"); expect(body.get("scope")).toBe("https://outlook.office365.com/.default"); expect(body.get("client_id")).toBe(CONFIG.clientId); expect(body.get("client_secret")).toBe(CONFIG.clientSecret); }); it("reuses the cached token until shortly before it expires", async () => { let now = 1_000_000; const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1", 3600)).mockResolvedValueOnce(tokenResponse("tok-2", 3600)); const provider = createMs365TokenProvider(CONFIG, { fetchImpl, now: () => now }); expect(await provider.getToken()).toBe("tok-1"); now += 50 * 60 * 1000; // 50 Minuten: noch gueltig expect(await provider.getToken()).toBe("tok-1"); now += 6 * 60 * 1000; // 56 Minuten: innerhalb der 5-Minuten-Reserve expect(await provider.getToken()).toBe("tok-2"); expect(fetchImpl).toHaveBeenCalledTimes(2); }); it("fetches a fresh token when a renewal is forced", async () => { const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1")).mockResolvedValueOnce(tokenResponse("tok-2")); const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); await provider.getToken(); expect(await provider.getToken(true)).toBe("tok-2"); }); it("shares one request between concurrent callers", async () => { const fetchImpl = vi.fn(async () => tokenResponse("tok-1")); const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); const tokens = await Promise.all([provider.getToken(), provider.getToken(), provider.getToken()]); expect(tokens).toEqual(["tok-1", "tok-1", "tok-1"]); expect(fetchImpl).toHaveBeenCalledTimes(1); }); it("reports the AADSTS code and never the client secret", async () => { const fetchImpl = vi.fn( async () => new Response( JSON.stringify({ error: "invalid_client", error_description: `AADSTS7000215: Invalid client secret provided. Ensure the secret being sent is the client secret value, not the client secret ID, for a secret added to app '${CONFIG.clientId}'. Secret: ${CONFIG.clientSecret}`, }), { status: 401 } ) ); const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); const error = await provider.getToken().catch((e: unknown) => e); expect(error).toBeInstanceOf(Ms365TokenError); expect((error as Ms365TokenError).aadCode).toBe("7000215"); expect((error as Error).message).not.toContain(CONFIG.clientSecret); }); it("does not cache a failed request", async () => { const fetchImpl = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValueOnce(tokenResponse("tok-1")); const provider = createMs365TokenProvider(CONFIG, { fetchImpl }); await expect(provider.getToken()).rejects.toBeInstanceOf(Ms365TokenError); await expect(provider.getToken()).resolves.toBe("tok-1"); }); it("rejects a tenant id that could change the request URL", () => { expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "evil.com/x?" })).toThrow(Ms365TokenError); expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "../common" })).toThrow(Ms365TokenError); }); });