Files
Miasma/tests/providers/azure/identity.test.ts
T

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");
});
});