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