import { createHash } from "node:crypto"; import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { generatePkcePair, generateOAuthState, buildEntraAuthorizeUrl, getVerifiedEmail, isEntraConfigured, EntraNotConfiguredError, } from "./entra"; const ENV_KEYS = ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET", "ENTRA_REDIRECT_URI"] as const; const originalEnv: Record = {}; beforeEach(() => { for (const key of ENV_KEYS) { originalEnv[key] = process.env[key]; delete process.env[key]; } }); afterEach(() => { for (const key of ENV_KEYS) { if (originalEnv[key] === undefined) delete process.env[key]; else process.env[key] = originalEnv[key]; } }); function configureEntra(): void { process.env.ENTRA_TENANT_ID = "tenant-123"; process.env.ENTRA_CLIENT_ID = "client-456"; process.env.ENTRA_CLIENT_SECRET = "secret-789"; process.env.ENTRA_REDIRECT_URI = "https://wabenhain.com/api/auth/entra/callback"; } describe("generatePkcePair", () => { test("produces a code_verifier in the RFC 7636 charset and length range", () => { const { codeVerifier } = generatePkcePair(); expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/); }); test("derives code_challenge as base64url(SHA256(code_verifier))", () => { const { codeVerifier, codeChallenge } = generatePkcePair(); const expected = createHash("sha256").update(codeVerifier).digest("base64url"); expect(codeChallenge).toBe(expected); }); test("generates a different pair on every call (not reused across requests)", () => { const a = generatePkcePair(); const b = generatePkcePair(); expect(a.codeVerifier).not.toBe(b.codeVerifier); }); }); describe("generateOAuthState", () => { test("generates a non-empty, URL-safe, unpredictable value each call", () => { const a = generateOAuthState(); const b = generateOAuthState(); expect(a).toMatch(/^[A-Za-z0-9_-]+$/); expect(a.length).toBeGreaterThanOrEqual(20); expect(a).not.toBe(b); }); }); describe("isEntraConfigured / buildEntraAuthorizeUrl", () => { test("reports not configured when env vars are missing", () => { expect(isEntraConfigured()).toBe(false); }); test("buildEntraAuthorizeUrl throws EntraNotConfiguredError when unconfigured", () => { expect(() => buildEntraAuthorizeUrl({ state: "s", codeChallenge: "c" })).toThrow(EntraNotConfiguredError); }); test("reports configured and builds a correct authorize URL once env vars are set", () => { configureEntra(); expect(isEntraConfigured()).toBe(true); const url = new URL(buildEntraAuthorizeUrl({ state: "my-state", codeChallenge: "my-challenge" })); expect(url.origin + url.pathname).toBe("https://login.microsoftonline.com/tenant-123/oauth2/v2.0/authorize"); expect(url.searchParams.get("client_id")).toBe("client-456"); expect(url.searchParams.get("redirect_uri")).toBe("https://wabenhain.com/api/auth/entra/callback"); expect(url.searchParams.get("response_type")).toBe("code"); expect(url.searchParams.get("state")).toBe("my-state"); expect(url.searchParams.get("code_challenge")).toBe("my-challenge"); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); // "openid" ist zwingend fuer OIDC, "email" liefert den verifizierten // E-Mail-Claim, den getVerifiedEmail() unten voraussetzt. expect(url.searchParams.get("scope")).toContain("openid"); expect(url.searchParams.get("scope")).toContain("email"); }); }); describe("getVerifiedEmail", () => { test("returns the lowercased, trimmed email claim when present and well-formed", () => { expect(getVerifiedEmail({ email: " Nick@Example.COM " })).toBe("nick@example.com"); }); test("returns null when the email claim is missing", () => { expect(getVerifiedEmail({})).toBeNull(); }); test("returns null when the email claim doesn't look like an email (never guesses/trusts a bad claim)", () => { expect(getVerifiedEmail({ email: "not-an-email" })).toBeNull(); }); });