389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|