277 lines
9.5 KiB
TypeScript
277 lines
9.5 KiB
TypeScript
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, unknown>): 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, unknown> = {}): 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");
|
|
});
|
|
});
|