import { beforeEach, describe, expect, it, mock } from "bun:test"; // ── Mock the auth module ──────────────────────────────────────────────────── const mockResolveAllIdentities = mock(); const mockResolveToken = mock(); mock.module("../../../src/providers/azure/auth", () => ({ resolveAllIdentities: mockResolveAllIdentities, resolveToken: mockResolveToken, })); // ── Mock the client module ────────────────────────────────────────────────── const mockAzureFetch = mock(); mock.module("../../../src/providers/azure/client", () => ({ azureFetch: mockAzureFetch, })); // ── Mock global fetch for MS Graph calls ──────────────────────────────────── const mockFetch = mock(); globalThis.fetch = mockFetch as unknown as typeof fetch; import { AzureIdentityService } from "../../../src/providers/azure/identity"; // ── Helpers ───────────────────────────────────────────────────────────────── function jwtEncode(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); return `${header}.${body}.signature`; } function makeTokenResponse(overrides: Record = {}): string { return JSON.stringify({ access_token: jwtEncode({ oid: overrides.oid ?? "obj-123", tid: overrides.tid ?? "tenant-456", appid: overrides.appid ?? "app-789", sub: overrides.sub ?? "sub-user", ...overrides, }), expires_on: String(Date.now() / 1000 + 3600), tenant_id: overrides.tid ?? "tenant-456", }); } function makeMsGraphUserResponse(displayName: string) { return new Response( JSON.stringify({ id: "user-1", displayName, userPrincipalName: "user@contoso.com", }), { status: 200 }, ); } function makeMsGraphSpResponse(displayName: string) { return new Response( JSON.stringify({ value: [ { id: "sp-1", appId: "app-789", displayName, servicePrincipalType: "Application", }, ], }), { status: 200 }, ); } // ── Tests ─────────────────────────────────────────────────────────────────── describe("AzureIdentityService", () => { let service: AzureIdentityService; beforeEach(() => { mockResolveAllIdentities.mockReset(); mockResolveToken.mockReset(); mockAzureFetch.mockReset(); mockFetch.mockReset(); service = new AzureIdentityService(); }); // ── No identities ───────────────────────────────────────────────────── it("returns failure when no identities found", async () => { mockResolveAllIdentities.mockResolvedValue([]); const result = await service.execute(); expect(result.success).toBe(false); expect(result.error?.message).toContain("No Azure identities found"); }); // ── Single identity from env service principal ───────────────────────── it("resolves identity from env service principal", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "env-service-principal", token: { token: jwtEncode({ oid: "obj-001", tid: "tenant-abc", appid: "app-xyz", }), expiresOn: Date.now() / 1000 + 3600, tenantId: "tenant-abc", }, }, ]); // MS Graph user lookup fails → falls back to service principal lookup mockFetch.mockRejectedValueOnce(new Error("Graph not scoped")); mockFetch.mockResolvedValueOnce(makeMsGraphSpResponse("MyApp")); // ARM subscription list mockAzureFetch.mockResolvedValue({ value: [ { subscriptionId: "sub-1", displayName: "Pay-As-You-Go", state: "Enabled", }, ], }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities).toHaveLength(1); expect(identities[0].source).toBe("env-service-principal"); expect(identities[0].tenantId).toBe("tenant-abc"); expect(identities[0].objectId).toBe("obj-001"); expect(identities[0].clientId).toBe("app-xyz"); expect(identities[0].displayName).toBe("MyApp"); expect(identities[0].subscriptionIds).toEqual(["sub-1"]); }); // ── Multiple identities ──────────────────────────────────────────────── it("resolves multiple identities from different sources", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "env-service-principal", token: { token: jwtEncode({ oid: "obj-1", tid: "t1", appid: "a1" }), expiresOn: Date.now() / 1000 + 3600, }, }, { source: "managed-identity", token: { token: jwtEncode({ oid: "obj-2", tid: "t2", appid: "a2" }), expiresOn: Date.now() / 1000 + 3600, }, }, ]); mockFetch.mockRejectedValue(new Error("no graph")); // both fail mockAzureFetch.mockResolvedValue({ value: [] }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities).toHaveLength(2); expect(identities[0].source).toBe("env-service-principal"); expect(identities[1].source).toBe("managed-identity"); }); // ── MS Graph user info ──────────────────────────────────────────────── it("resolves display name from MS Graph user endpoint", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "env-service-principal", token: { token: jwtEncode({ oid: "obj-1", tid: "t1" }), expiresOn: Date.now() / 1000 + 3600, }, }, ]); mockFetch.mockResolvedValueOnce(makeMsGraphUserResponse("Jane Doe")); mockAzureFetch.mockResolvedValue({ value: [] }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities[0].displayName).toBe("Jane Doe"); }); // ── JWT claim fallbacks ──────────────────────────────────────────────── it("falls back to sub claim when oid missing", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "env-service-principal", token: { token: jwtEncode({ tid: "t1", sub: "fallback-sub" }), expiresOn: Date.now() / 1000 + 3600, }, }, ]); mockFetch.mockRejectedValue(new Error("no graph")); mockAzureFetch.mockResolvedValue({ value: [] }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities[0].objectId).toBe("fallback-sub"); }); // ── ARM subscription filtering ───────────────────────────────────────── it("filters out disabled subscriptions", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "env-service-principal", token: { token: jwtEncode({ oid: "obj-1", tid: "t1" }), expiresOn: Date.now() / 1000 + 3600, }, }, ]); mockFetch.mockRejectedValue(new Error("no graph")); mockAzureFetch.mockResolvedValue({ value: [ { subscriptionId: "enabled-1", displayName: "A", state: "Enabled" }, { subscriptionId: "disabled-1", displayName: "B", state: "Disabled" }, { subscriptionId: "pastdue-1", displayName: "C", state: "PastDue" }, ], }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities[0].subscriptionIds).toEqual(["enabled-1"]); expect(identities[0].subscriptionIds).not.toContain("disabled-1"); expect(identities[0].subscriptionIds).not.toContain("pastdue-1"); }); // ── Falls back to token claims when tenantId not in token object ─────── it("uses token.tenantId when tid claim is absent", async () => { mockResolveAllIdentities.mockResolvedValue([ { source: "managed-identity", token: { token: jwtEncode({ oid: "obj-1" }), expiresOn: Date.now() / 1000 + 3600, tenantId: "from-token-obj", }, }, ]); mockFetch.mockRejectedValue(new Error("no graph")); mockAzureFetch.mockResolvedValue({ value: [] }); const result = await service.execute(); expect(result.success).toBe(true); const identities = result.data as any[]; expect(identities[0].tenantId).toBe("from-token-obj"); }); });