/** * Unit tests for VaultClient. * Mocks the node-vault library to avoid real Vault connections. */ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { VaultClient, createVaultClientFromEnv } from '../../../src/vault/VaultClient.js'; import { CredentialError } from '../../../src/utils/errors.js'; // ─── Mock node-vault ──────────────────────────────────────────────────────── const mockWrite = jest.fn<() => Promise>(); const mockRead = jest.fn<() => Promise>(); const mockDelete = jest.fn<() => Promise>(); jest.mock('node-vault', () => { return jest.fn(() => ({ write: mockWrite, read: mockRead, delete: mockDelete, })); }); // ─── Helpers ──────────────────────────────────────────────────────────────── const AGENT_ID = 'agent-uuid-1234'; const CRED_ID = 'cred-uuid-5678'; const PLAIN_SECRET = 'super-secret-value'; function makeClient(): VaultClient { return new VaultClient('http://127.0.0.1:8200', 'test-token', 'secret'); } // ─── Tests ────────────────────────────────────────────────────────────────── describe('VaultClient', () => { beforeEach(() => { jest.clearAllMocks(); }); // ── writeSecret ──────────────────────────────────────────────────────────── describe('writeSecret', () => { it('writes the secret to the correct KV v2 path and returns the path', async () => { mockWrite.mockResolvedValue({}); const client = makeClient(); const path = await client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET); expect(mockWrite).toHaveBeenCalledWith( `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`, { data: { clientSecret: PLAIN_SECRET } }, ); expect(path).toBe(`secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`); }); it('throws CredentialError when Vault write fails', async () => { mockWrite.mockRejectedValue(new Error('connection refused')); const client = makeClient(); await expect(client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET)) .rejects.toThrow(CredentialError); }); it('CredentialError on write failure has code VAULT_WRITE_ERROR', async () => { mockWrite.mockRejectedValue(new Error('forbidden')); const client = makeClient(); await expect(client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET)) .rejects.toMatchObject({ code: 'VAULT_WRITE_ERROR' }); }); }); // ── readSecret ───────────────────────────────────────────────────────────── describe('readSecret', () => { it('reads and returns the stored secret', async () => { mockRead.mockResolvedValue({ data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} }, }); const client = makeClient(); const secret = await client.readSecret(AGENT_ID, CRED_ID); expect(mockRead).toHaveBeenCalledWith( `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`, ); expect(secret).toBe(PLAIN_SECRET); }); it('throws CredentialError when secret field is missing', async () => { mockRead.mockResolvedValue({ data: { data: {}, metadata: {} } }); const client = makeClient(); await expect(client.readSecret(AGENT_ID, CRED_ID)) .rejects.toMatchObject({ code: 'VAULT_SECRET_MISSING' }); }); it('throws CredentialError when Vault read fails', async () => { mockRead.mockRejectedValue(new Error('404 not found')); const client = makeClient(); await expect(client.readSecret(AGENT_ID, CRED_ID)) .rejects.toMatchObject({ code: 'VAULT_READ_ERROR' }); }); }); // ── verifySecret ─────────────────────────────────────────────────────────── describe('verifySecret', () => { it('returns true when candidate matches stored secret', async () => { mockRead.mockResolvedValue({ data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} }, }); const client = makeClient(); const result = await client.verifySecret(AGENT_ID, CRED_ID, PLAIN_SECRET); expect(result).toBe(true); }); it('returns false when candidate does not match stored secret', async () => { mockRead.mockResolvedValue({ data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} }, }); const client = makeClient(); const result = await client.verifySecret(AGENT_ID, CRED_ID, 'wrong-secret'); expect(result).toBe(false); }); it('returns false when Vault read fails (does not throw)', async () => { mockRead.mockRejectedValue(new Error('vault sealed')); const client = makeClient(); const result = await client.verifySecret(AGENT_ID, CRED_ID, PLAIN_SECRET); expect(result).toBe(false); }); it('returns false when lengths differ (constant-time)', async () => { mockRead.mockResolvedValue({ data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} }, }); const client = makeClient(); const result = await client.verifySecret(AGENT_ID, CRED_ID, 'short'); expect(result).toBe(false); }); }); // ── deleteSecret ─────────────────────────────────────────────────────────── describe('deleteSecret', () => { it('calls delete on the metadata path', async () => { mockDelete.mockResolvedValue({}); const client = makeClient(); await client.deleteSecret(AGENT_ID, CRED_ID); expect(mockDelete).toHaveBeenCalledWith( `secret/metadata/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`, ); }); it('throws CredentialError when Vault delete fails', async () => { mockDelete.mockRejectedValue(new Error('permission denied')); const client = makeClient(); await expect(client.deleteSecret(AGENT_ID, CRED_ID)) .rejects.toMatchObject({ code: 'VAULT_DELETE_ERROR' }); }); }); }); // ─── createVaultClientFromEnv ───────────────────────────────────────────────── describe('createVaultClientFromEnv', () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; }); afterEach(() => { process.env = originalEnv; }); it('returns null when VAULT_ADDR is not set', () => { delete process.env['VAULT_ADDR']; delete process.env['VAULT_TOKEN']; expect(createVaultClientFromEnv()).toBeNull(); }); it('returns null when VAULT_TOKEN is not set', () => { process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200'; delete process.env['VAULT_TOKEN']; expect(createVaultClientFromEnv()).toBeNull(); }); it('returns a VaultClient when both VAULT_ADDR and VAULT_TOKEN are set', () => { process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200'; process.env['VAULT_TOKEN'] = 'test-token'; const client = createVaultClientFromEnv(); expect(client).toBeInstanceOf(VaultClient); }); it('uses default mount "secret" when VAULT_MOUNT is not set', () => { process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200'; process.env['VAULT_TOKEN'] = 'test-token'; delete process.env['VAULT_MOUNT']; // VaultClient instance created — mount is internal, just verify no throw expect(() => createVaultClientFromEnv()).not.toThrow(); }); });