Azure Key Vault Testing: Secrets Management in CI/CD Pipelines

Azure Key Vault Testing: Secrets Management in CI/CD Pipelines

Azure Key Vault centralizes secret management, certificate storage, and cryptographic key operations. Testing applications that depend on Key Vault requires careful strategy: you don't want to skip Key Vault in tests (then you're not testing real behavior) but you also can't hardcode production secrets. This guide covers the right approach for each scenario.

The Testing Strategy

Four approaches, in order of preference for different scenarios:

  1. Local secret provider — replace Key Vault with environment variables or a local mock during unit tests
  2. Azure Key Vault test instance — a dedicated Key Vault in a non-production subscription for integration tests
  3. Managed Identity in CI — GitHub Actions or Azure DevOps using OIDC to authenticate to a test Key Vault
  4. Mock with @azure/identity test credentials — for testing the SDK interaction without a real vault

Abstracting Key Vault Behind an Interface

The most maintainable approach is hiding Key Vault behind an interface your code depends on:

// secrets.interface.ts
export interface SecretsProvider {
  getSecret(name: string): Promise<string>;
  setSecret(name: string, value: string): Promise<void>;
}

// azure-keyvault.provider.ts
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';

export class AzureKeyVaultProvider implements SecretsProvider {
  private client: SecretClient;
  
  constructor(vaultUrl: string) {
    this.client = new SecretClient(vaultUrl, new DefaultAzureCredential());
  }
  
  async getSecret(name: string): Promise<string> {
    const { value } = await this.client.getSecret(name);
    if (!value) throw new Error(`Secret ${name} is empty`);
    return value;
  }
  
  async setSecret(name: string, value: string): Promise<void> {
    await this.client.setSecret(name, value);
  }
}

// env.provider.ts — for local development and testing
export class EnvironmentSecretsProvider implements SecretsProvider {
  private prefix: string;
  
  constructor(prefix = 'SECRET_') {
    this.prefix = prefix;
  }
  
  async getSecret(name: string): Promise<string> {
    const envKey = `${this.prefix}${name.toUpperCase().replace(/-/g, '_')}`;
    const value = process.env[envKey];
    if (!value) throw new Error(`Secret ${name} not found in environment (${envKey})`);
    return value;
  }
  
  async setSecret(name: string, value: string): Promise<void> {
    const envKey = `${this.prefix}${name.toUpperCase().replace(/-/g, '_')}`;
    process.env[envKey] = value;
  }
}

Unit tests use the environment provider:

// database.service.test.ts
import { DatabaseService } from './database.service';
import { EnvironmentSecretsProvider } from './env.provider';

describe('DatabaseService', () => {
  const secrets = new EnvironmentSecretsProvider('TEST_SECRET_');
  
  beforeEach(() => {
    process.env.TEST_SECRET_DB_PASSWORD = 'test-password-123';
    process.env.TEST_SECRET_DB_CONNECTION_STRING = 'postgresql://test:test@localhost/testdb';
  });
  
  test('connects using secret from provider', async () => {
    const service = new DatabaseService(secrets);
    await service.connect();
    
    expect(service.isConnected()).toBe(true);
  });
  
  test('throws if secret missing', async () => {
    delete process.env.TEST_SECRET_DB_PASSWORD;
    
    const service = new DatabaseService(secrets);
    
    await expect(service.connect()).rejects.toThrow('Secret db-password not found');
  });
});

Integration Tests Against Real Key Vault

For integration tests, use a dedicated test Key Vault with Managed Identity:

// keyvault.integration.test.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential, EnvironmentCredential } = require('@azure/identity');

const VAULT_URL = process.env.AZURE_KEY_VAULT_URL;
const isIntegration = !!VAULT_URL;

// Skip if no Key Vault URL provided
const describeIntegration = isIntegration ? describe : describe.skip;

describeIntegration('Key Vault integration', () => {
  let client;
  const testPrefix = `test-${Date.now()}`;
  
  beforeAll(() => {
    client = new SecretClient(VAULT_URL, new DefaultAzureCredential());
  });
  
  afterAll(async () => {
    // Clean up test secrets
    const secrets = client.listPropertiesOfSecrets();
    for await (const secret of secrets) {
      if (secret.name.startsWith(testPrefix)) {
        await client.beginDeleteSecret(secret.name);
      }
    }
  });
  
  test('stores and retrieves a secret', async () => {
    const secretName = `${testPrefix}-test-secret`;
    const secretValue = `value-${Date.now()}`;
    
    await client.setSecret(secretName, secretValue);
    
    const { value } = await client.getSecret(secretName);
    expect(value).toBe(secretValue);
  });
  
  test('retrieves a specific version', async () => {
    const secretName = `${testPrefix}-versioned`;
    
    // Create two versions
    const { properties: v1 } = await client.setSecret(secretName, 'version-1');
    const { properties: v2 } = await client.setSecret(secretName, 'version-2');
    
    // Retrieve specific version
    const { value: v1Value } = await client.getSecret(secretName, {
      version: v1.version,
    });
    const { value: v2Value } = await client.getSecret(secretName, {
      version: v2.version,
    });
    
    expect(v1Value).toBe('version-1');
    expect(v2Value).toBe('version-2');
    
    // Latest (no version) should be v2
    const { value: latest } = await client.getSecret(secretName);
    expect(latest).toBe('version-2');
  });
  
  test('disabled secret cannot be retrieved', async () => {
    const secretName = `${testPrefix}-disabled`;
    
    await client.setSecret(secretName, 'should-not-be-readable');
    await client.updateSecretProperties(secretName, '', {
      enabled: false,
    });
    
    await expect(client.getSecret(secretName)).rejects.toMatchObject({
      statusCode: 403,
    });
  });
  
  test('secret with expiry is auto-disabled after expiry', async () => {
    const secretName = `${testPrefix}-expiring`;
    const expiresOn = new Date(Date.now() + 2000); // 2 seconds
    
    await client.setSecret(secretName, 'expiring-value', {
      expiresOn,
    });
    
    // Should be retrievable now
    const { value } = await client.getSecret(secretName);
    expect(value).toBe('expiring-value');
    
    // After expiry, should not be retrievable
    await new Promise(resolve => setTimeout(resolve, 3000));
    
    await expect(client.getSecret(secretName)).rejects.toMatchObject({
      statusCode: 403,
    });
  }, 10000);
});

GitHub Actions with OIDC Authentication

The recommended CI approach — no stored credentials:

# .github/workflows/integration-tests.yml
name: Integration Tests

on: [push]

permissions:
  id-token: write  # Required for OIDC
  contents: read

jobs:
  integration:
    runs-on: ubuntu-latest
    environment: test  # Uses environment for Key Vault access
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Login to Azure via OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Get secrets from Key Vault
        uses: azure/get-keyvault-secrets@v1
        with:
          keyvault: my-test-key-vault
          secrets: 'db-password, api-key, jwt-secret'
        id: kv-secrets
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      
      - run: npm ci
      
      - name: Run integration tests
        run: npm run test:integration
        env:
          # Secrets injected from Key Vault
          DB_PASSWORD: ${{ steps.kv-secrets.outputs.db-password }}
          API_KEY: ${{ steps.kv-secrets.outputs.api-key }}
          JWT_SECRET: ${{ steps.kv-secrets.outputs.jwt-secret }}
          # Key Vault URL for tests that use DefaultAzureCredential
          AZURE_KEY_VAULT_URL: https://my-test-key-vault.vault.azure.net

Testing Secret Rotation

Applications must handle secret rotation gracefully:

// rotation.test.js
class CachingSecretsProvider {
  constructor(client, ttlMs = 300000) {
    this.client = client;
    this.cache = new Map();
    this.ttlMs = ttlMs;
  }
  
  async getSecret(name) {
    const cached = this.cache.get(name);
    if (cached && Date.now() - cached.timestamp < this.ttlMs) {
      return cached.value;
    }
    
    const { value } = await this.client.getSecret(name);
    this.cache.set(name, { value, timestamp: Date.now() });
    return value;
  }
  
  invalidate(name) {
    this.cache.delete(name);
  }
}

describe('Secret rotation handling', () => {
  test('caches secrets within TTL', async () => {
    const mockClient = {
      getSecret: jest.fn().mockResolvedValue({ value: 'secret-value' }),
    };
    
    const provider = new CachingSecretsProvider(mockClient, 60000);
    
    await provider.getSecret('api-key');
    await provider.getSecret('api-key');
    await provider.getSecret('api-key');
    
    // Should only call Key Vault once (cached)
    expect(mockClient.getSecret).toHaveBeenCalledTimes(1);
  });
  
  test('re-fetches after cache invalidation', async () => {
    const mockClient = {
      getSecret: jest.fn()
        .mockResolvedValueOnce({ value: 'old-secret' })
        .mockResolvedValueOnce({ value: 'new-secret' }),
    };
    
    const provider = new CachingSecretsProvider(mockClient, 60000);
    
    const first = await provider.getSecret('api-key');
    expect(first).toBe('old-secret');
    
    // Simulate rotation — invalidate cache
    provider.invalidate('api-key');
    
    const second = await provider.getSecret('api-key');
    expect(second).toBe('new-secret');
    expect(mockClient.getSecret).toHaveBeenCalledTimes(2);
  });
  
  test('handles Key Vault errors gracefully with stale cache', async () => {
    const mockClient = {
      getSecret: jest.fn()
        .mockResolvedValueOnce({ value: 'cached-value' })
        .mockRejectedValueOnce(new Error('Key Vault unavailable')),
    };
    
    const provider = new CachingSecretsProvider(mockClient, 1); // 1ms TTL
    
    // Cache the value
    await provider.getSecret('api-key');
    
    // Force cache expiry
    await new Promise(resolve => setTimeout(resolve, 10));
    
    // Key Vault is down — consider falling back to stale cache
    // This behavior depends on your resilience requirements
  });
});

Testing Certificate Operations

describe('Certificate operations', () => {
  const { CertificateClient } = require('@azure/keyvault-certificates');
  
  let certClient;
  
  beforeAll(() => {
    certClient = new CertificateClient(VAULT_URL, new DefaultAzureCredential());
  });
  
  test('creates and retrieves a self-signed certificate', async () => {
    const certName = `test-cert-${Date.now()}`;
    
    // Start certificate creation (async in Key Vault)
    const poller = await certClient.beginCreateCertificate(certName, {
      issuerName: 'Self',
      subject: 'CN=test.example.com',
      validityInMonths: 1,
    });
    
    // Wait for completion
    const certificate = await poller.pollUntilDone();
    
    expect(certificate.name).toBe(certName);
    expect(certificate.properties.enabled).toBe(true);
    expect(certificate.properties.expiresOn).toBeTruthy();
    
    // Clean up
    await certClient.beginDeleteCertificate(certName);
  }, 60000);
  
  test('retrieves certificate thumbprint', async () => {
    const { properties } = await certClient.getCertificate('existing-cert');
    
    expect(properties.x509Thumbprint).toBeTruthy();
    expect(properties.expiresOn.getTime()).toBeGreaterThan(Date.now());
  });
});

Access Policy Testing

Verify your app only requests permissions it actually needs:

test('application uses least-privilege access', async () => {
  // Try operations that should be allowed
  const { value } = await client.getSecret('db-password');
  expect(value).toBeTruthy();
  
  // Try operations that should NOT be allowed
  // (Your app should only have Get permission, not Set)
  await expect(client.setSecret('new-secret', 'unauthorized')).rejects.toMatchObject({
    statusCode: 403,
  });
  
  // Should not be able to delete
  await expect(client.beginDeleteSecret('db-password')).rejects.toMatchObject({
    statusCode: 403,
  });
});

The key principle: test Key Vault integration properly at two levels. Unit tests mock the provider completely so they're fast and offline. Integration tests hit a real Key Vault instance in CI using OIDC authentication — no long-lived credentials stored anywhere. Production uses Managed Identity, which is the same credential flow and validates that your access policies are correct.

Read more

Start now free