import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; // ── Mock global fetch ─────────────────────────────────────────────────────── const mockFetch = mock(); globalThis.fetch = mockFetch as unknown as typeof fetch; import { fromEnvServicePrincipal, fromManagedIdentity, fromWorkloadIdentity, resolveAllIdentities, resolveToken, } from "../../../src/providers/azure/auth"; // ── Helpers ───────────────────────────────────────────────────────────────── function setEnv(kv: Record) { for (const [k, v] of Object.entries(kv)) process.env[k] = v; } function clearEnv(keys: string[]) { for (const k of keys) delete process.env[k]; } function tokenResponse(overrides: Record = {}) { return new Response( JSON.stringify({ access_token: overrides.access_token ?? "eyJ...fake-token", expires_in: overrides.expires_in ?? 3600, expires_on: overrides.expires_on ?? String(Date.now() / 1000 + 3600), tenant_id: overrides.tenant_id ?? "tenant-123", client_id: overrides.client_id ?? "client-456", }), { status: overrides.status ?? 200 }, ); } const ENV_KEYS = [ "AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "ARM_CLIENT_ID", "ARM_CLIENT_SECRET", "ARM_TENANT_ID", "TENANT_ID", "AZURE_FEDERATED_TOKEN_FILE", "ARM_OIDC_TOKEN_FILE_PATH", "IDENTITY_ENDPOINT", "IDENTITY_HEADER", ]; // ── Tests ─────────────────────────────────────────────────────────────────── describe("Azure auth", () => { beforeEach(() => { mockFetch.mockImplementation(() => Promise.reject(new Error("mock fetch not configured for this test")), ); clearEnv(ENV_KEYS); }); afterEach(() => { clearEnv(ENV_KEYS); }); // ═══════════════════════════════════════════════════════════════════════ // resolveToken — credential chain // ═══════════════════════════════════════════════════════════════════════ describe("resolveToken", () => { it("throws when no credentials are available", async () => { mockFetch.mockImplementation(() => Promise.reject(new Error("connect ECONNREFUSED")), ); await expect( resolveToken("https://vault.azure.net/.default", undefined, 1000), ).rejects.toThrow("No Azure credentials available"); }); it("uses env service principal when configured", async () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "sp-token" })), ); // Use unique scope to avoid cache contamination from test 1 const result = await resolveToken("https://vault.azure.net/.default"); expect(result.token).toBe("sp-token"); expect(result.tenantId).toBe("tenant-123"); }); it("falls through to managed identity when env vars incomplete", async () => { // Only client_id set — no secret or tenant setEnv({ AZURE_CLIENT_ID: "c1" }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "mi-token" })), ); // Use a different scope to avoid cache contamination const result = await resolveToken( "https://management.azure.com/.default", ); expect(result.token).toBe("mi-token"); }); it("caches tokens and reuses them", async () => { setEnv({ AZURE_TENANT_ID: "t2", AZURE_CLIENT_ID: "c2", AZURE_CLIENT_SECRET: "s2", }); let callCount = 0; mockFetch.mockImplementation(() => { callCount++; return Promise.resolve( tokenResponse({ access_token: `call-${callCount}` }), ); }); // Use unique scope to isolate cache from other tests const scope = "https://graph.microsoft.com/.default"; const r1 = await resolveToken(scope); const r2 = await resolveToken(scope); // Both calls should return the same cached token expect(r1.token).toBe(r2.token); expect(callCount).toBe(1); }); }); // ═══════════════════════════════════════════════════════════════════════ // fromEnvServicePrincipal // ═══════════════════════════════════════════════════════════════════════ describe("fromEnvServicePrincipal", () => { it("returns null when AZURE_TENANT_ID is missing", () => { setEnv({ AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1" }); expect(fromEnvServicePrincipal()).toBeNull(); }); it("returns null when AZURE_CLIENT_ID is missing", () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_SECRET: "s1" }); expect(fromEnvServicePrincipal()).toBeNull(); }); it("returns null when AZURE_CLIENT_SECRET is missing", () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1" }); expect(fromEnvServicePrincipal()).toBeNull(); }); it("returns credential when all env vars are set", () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1", }); const cred = fromEnvServicePrincipal(); expect(cred).not.toBeNull(); expect(cred!.label).toBe("env-service-principal"); }); it("obtains a token successfully with valid credentials", async () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "tok" })), ); const cred = fromEnvServicePrincipal()!; const token = await cred.getToken("https://vault.azure.net/.default"); expect(token.token).toBe("tok"); }); it("throws on non-200 response", async () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "bad-client", AZURE_CLIENT_SECRET: "bad-secret", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "x", status: 401 })), ); const cred = fromEnvServicePrincipal()!; await expect( cred.getToken("https://vault.azure.net/.default"), ).rejects.toThrow("Client credentials token request failed"); }); }); // ═══════════════════════════════════════════════════════════════════════ // fromManagedIdentity // ═══════════════════════════════════════════════════════════════════════ describe("fromManagedIdentity", () => { it("returns a credential unconditionally", () => { const cred = fromManagedIdentity(); expect(cred.label).toBe("managed-identity"); }); it("obtains a token via IMDS", async () => { mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "mi-tok" })), ); const cred = fromManagedIdentity(); const token = await cred.getToken("https://vault.azure.net/.default"); expect(token.token).toBe("mi-tok"); }); it("obtains a token via App Service endpoint", async () => { setEnv({ IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/", IDENTITY_HEADER: "abc123header", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "app-svc-tok" })), ); const cred = fromManagedIdentity(); const token = await cred.getToken("https://vault.azure.net/.default"); expect(token.token).toBe("app-svc-tok"); }); it("handles expires_on as relative seconds (App Service)", async () => { setEnv({ IDENTITY_ENDPOINT: "http://127.0.0.1:41567/MSI/token/", IDENTITY_HEADER: "hdr", }); // App Service returns expires_on as relative seconds (< 1M) mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ expires_on: "3600" })), ); const cred = fromManagedIdentity(); const now = Date.now() / 1000; const token = await cred.getToken("https://vault.azure.net/.default"); // Should be treated as relative (now + 3600), not absolute epoch 3600 expect(token.expiresOn).toBeGreaterThan(1_000_000_000); expect(token.expiresOn).toBeGreaterThan(now); expect(token.expiresOn).toBeLessThan(now + 3700); }); it("handles expires_on as absolute epoch (IMDS)", async () => { mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ expires_on: "2000000000" })), ); const cred = fromManagedIdentity(); const token = await cred.getToken("https://vault.azure.net/.default"); expect(token.expiresOn).toBe(2_000_000_000); }); }); // ═══════════════════════════════════════════════════════════════════════ // fromWorkloadIdentity // ═══════════════════════════════════════════════════════════════════════ describe("fromWorkloadIdentity", () => { it("returns null without AZURE_FEDERATED_TOKEN_FILE", () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1", }); expect(fromWorkloadIdentity()).toBeNull(); }); }); // ═══════════════════════════════════════════════════════════════════════ // resolveAllIdentities // ═══════════════════════════════════════════════════════════════════════ describe("resolveAllIdentities", () => { it("returns empty array when all sources fail", async () => { mockFetch.mockImplementation(() => Promise.reject(new Error("network error")), ); const results = await resolveAllIdentities( "https://management.azure.com/.default", 500, ); expect(results).toEqual([]); }); it("returns tokens from each available source", async () => { setEnv({ AZURE_TENANT_ID: "t1", AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse({ access_token: "sp-tok" })), ); const results = await resolveAllIdentities( "https://management.azure.com/.default", 500, ); // Both env SP and managed identity succeed expect(results).toHaveLength(2); expect(results[0].source).toBe("env-service-principal"); expect(results[0].token.token).toBe("sp-tok"); expect(results[1].source).toBe("managed-identity"); }); }); // ═══════════════════════════════════════════════════════════════════════ // ARM_TENANT_ID / ARM_CLIENT_ID fallback // ═══════════════════════════════════════════════════════════════════════ describe("ARM_ env var fallbacks", () => { it("uses ARM_TENANT_ID when AZURE_TENANT_ID is not set", async () => { setEnv({ ARM_TENANT_ID: "arm-t", AZURE_CLIENT_ID: "c1", AZURE_CLIENT_SECRET: "s1", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse())); const cred = fromEnvServicePrincipal()!; const token = await cred.getToken("https://vault.azure.net/.default"); // The credential should work — tenant comes from ARM_TENANT_ID expect(token).toBeDefined(); expect(token.token).toBeDefined(); }); it("uses ARM_CLIENT_ID and ARM_CLIENT_SECRET when AZURE_ variants missing", async () => { setEnv({ AZURE_TENANT_ID: "t1", ARM_CLIENT_ID: "arm-c", ARM_CLIENT_SECRET: "arm-s", }); mockFetch.mockImplementation(() => Promise.resolve(tokenResponse())); const cred = fromEnvServicePrincipal()!; const token = await cred.getToken("https://vault.azure.net/.default"); // The credential should work — client/secret from ARM_* vars expect(token).toBeDefined(); expect(token.token).toBeDefined(); }); }); });