Upload files to "tests/providers/azure"

This commit is contained in:
2026-07-03 02:49:46 +00:00
parent 10175cd4d6
commit 96c1dae225
3 changed files with 1036 additions and 0 deletions
+372
View File
@@ -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<string, string>) {
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<string, unknown> = {}) {
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();
});
});
});
+276
View File
@@ -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, 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");
});
});
+388
View File
@@ -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);
});
});