feat(phase-2): workstream 1 — HashiCorp Vault credential storage

Vault is optional — server falls back to bcrypt (Phase 1 behaviour)
when VAULT_ADDR is not set. Full coexistence: existing bcrypt credentials
continue to work until rotated.

Changes:
- src/vault/VaultClient.ts — wraps node-vault KV v2; writeSecret,
  readSecret, verifySecret (constant-time), deleteSecret
- src/db/migrations/005_add_vault_path.sql — vault_path column on credentials
- CredentialRepository — createWithVaultPath, updateVaultPath methods
- CredentialService — routes generate/rotate through Vault when configured;
  bcrypt path unchanged
- OAuth2Service — verifies via Vault when vaultPath set, bcrypt otherwise
- src/app.ts — createVaultClientFromEnv() wired into service layer
- ICredentialRow — vaultPath field added
- docs/devops/environment-variables.md — VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- docs/devops/vault-setup.md — dev quickstart, production config, migration guide
- tests: 33/33 unit tests pass (VaultClient + CredentialService Vault path)
- node-vault + @types/node-vault installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:02:33 +00:00
parent 7593bfe1c1
commit 90a4addb21
14 changed files with 1064 additions and 36 deletions

View File

@@ -0,0 +1,206 @@
/**
* 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<unknown>>();
const mockRead = jest.fn<() => Promise<unknown>>();
const mockDelete = jest.fn<() => Promise<unknown>>();
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();
});
});