282 lines
9.6 KiB
TypeScript
282 lines
9.6 KiB
TypeScript
/**
|
|
* Vault REST API Route Tests
|
|
*
|
|
* Tests all 4 vault endpoints using a real VaultStore on a temp directory:
|
|
* GET /api/vault — list secrets (no values)
|
|
* POST /api/vault — add/update a secret
|
|
* DELETE /api/vault/:name — delete a secret
|
|
* POST /api/vault/:name/reveal — decrypt and return the full value
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import Fastify from 'fastify';
|
|
import { VaultStore } from '@waggle/core';
|
|
import { vaultRoutes } from '../../src/local/routes/vault.js';
|
|
|
|
/** Shape of a secret summary in the GET /api/vault response (no value exposed). */
|
|
interface VaultSecretSummary {
|
|
name: string;
|
|
type: string;
|
|
updatedAt: string;
|
|
isCommon: boolean;
|
|
}
|
|
|
|
function createTestServer(vault: VaultStore) {
|
|
const server = Fastify({ logger: false });
|
|
server.decorate('vault', vault);
|
|
server.register(vaultRoutes);
|
|
return server;
|
|
}
|
|
|
|
describe('Vault Routes', () => {
|
|
let tmpDir: string;
|
|
let vault: VaultStore;
|
|
let server: ReturnType<typeof Fastify>;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = path.join(os.tmpdir(), `waggle-vault-routes-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
vault = new VaultStore(tmpDir);
|
|
server = createTestServer(vault);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await server.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── GET /api/vault ───────────────────────────────────────────────
|
|
|
|
describe('GET /api/vault', () => {
|
|
it('returns empty list initially', async () => {
|
|
const res = await server.inject({ method: 'GET', url: '/api/vault' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.secrets).toEqual([]);
|
|
expect(body.suggestedKeys).toBeDefined();
|
|
expect(Array.isArray(body.suggestedKeys)).toBe(true);
|
|
});
|
|
|
|
it('returns secrets after adding', async () => {
|
|
vault.set('ANTHROPIC_API_KEY', 'sk-ant-test-123', { credentialType: 'api_key' });
|
|
vault.set('MY_CUSTOM_KEY', 'custom-value-456');
|
|
|
|
const res = await server.inject({ method: 'GET', url: '/api/vault' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.secrets).toHaveLength(2);
|
|
|
|
const anthropicSecret = body.secrets.find((s: VaultSecretSummary) => s.name === 'ANTHROPIC_API_KEY');
|
|
expect(anthropicSecret).toBeDefined();
|
|
expect(anthropicSecret.type).toBe('api_key');
|
|
expect(anthropicSecret.updatedAt).toBeDefined();
|
|
// Ensure value is NOT exposed in list
|
|
expect(anthropicSecret.value).toBeUndefined();
|
|
});
|
|
|
|
it('sets isCommon flag correctly', async () => {
|
|
vault.set('ANTHROPIC_API_KEY', 'sk-ant-test');
|
|
vault.set('MY_PRIVATE_KEY', 'secret');
|
|
|
|
const res = await server.inject({ method: 'GET', url: '/api/vault' });
|
|
const body = res.json();
|
|
|
|
const anthropic = body.secrets.find((s: VaultSecretSummary) => s.name === 'ANTHROPIC_API_KEY');
|
|
const custom = body.secrets.find((s: VaultSecretSummary) => s.name === 'MY_PRIVATE_KEY');
|
|
|
|
// ANTHROPIC_API_KEY is not in the new COMMON_KEYS (now uses 'anthropic' as provider ID)
|
|
expect(anthropic.isCommon).toBe(false);
|
|
expect(custom.isCommon).toBe(false);
|
|
});
|
|
|
|
it('suggestedKeys excludes keys already in vault', async () => {
|
|
vault.set('anthropic', 'sk-ant-test');
|
|
|
|
const res = await server.inject({ method: 'GET', url: '/api/vault' });
|
|
const body = res.json();
|
|
|
|
expect(body.suggestedKeys).not.toContain('anthropic');
|
|
expect(body.suggestedKeys).toContain('openai');
|
|
expect(body.suggestedKeys).toContain('TAVILY_API_KEY');
|
|
});
|
|
|
|
it('suggestedKeys contains common keys when vault is empty', async () => {
|
|
const res = await server.inject({ method: 'GET', url: '/api/vault' });
|
|
const body = res.json();
|
|
|
|
expect(body.suggestedKeys).toContain('anthropic');
|
|
expect(body.suggestedKeys).toContain('openai');
|
|
expect(body.suggestedKeys).toContain('GITHUB_TOKEN');
|
|
expect(body.suggestedKeys.length).toBeGreaterThan(20);
|
|
// Also has categorized suggestions
|
|
expect(body.suggestedSecrets).toBeDefined();
|
|
expect(body.suggestedSecrets.length).toBeGreaterThan(0);
|
|
expect(body.suggestedSecrets[0].category).toBe('LLM Providers');
|
|
});
|
|
});
|
|
|
|
// ── POST /api/vault ──────────────────────────────────────────────
|
|
|
|
describe('POST /api/vault', () => {
|
|
it('adds a new secret', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault',
|
|
payload: { name: 'OPENAI_API_KEY', value: 'sk-test-key-abc' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.name).toBe('OPENAI_API_KEY');
|
|
|
|
// Verify it was actually stored
|
|
const stored = vault.get('OPENAI_API_KEY');
|
|
expect(stored).not.toBeNull();
|
|
expect(stored!.value).toBe('sk-test-key-abc');
|
|
});
|
|
|
|
it('adds a secret with type metadata', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault',
|
|
payload: { name: 'GITHUB_TOKEN', value: 'ghp_token', type: 'bearer' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const stored = vault.get('GITHUB_TOKEN');
|
|
expect(stored!.metadata?.credentialType).toBe('bearer');
|
|
});
|
|
|
|
it('updates an existing secret', async () => {
|
|
vault.set('MY_KEY', 'old-value');
|
|
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault',
|
|
payload: { name: 'MY_KEY', value: 'new-value' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const stored = vault.get('MY_KEY');
|
|
expect(stored!.value).toBe('new-value');
|
|
});
|
|
|
|
it('rejects missing name', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault',
|
|
payload: { value: 'some-value' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
// validateBody returns a generic error + structured zod issues naming the
|
|
// offending field (path), rather than embedding the field in the message.
|
|
const body = res.json();
|
|
expect(body.error).toBe('Invalid request body');
|
|
expect(JSON.stringify(body.issues)).toContain('name');
|
|
});
|
|
|
|
it('rejects missing value', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault',
|
|
payload: { name: 'SOME_KEY' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const body = res.json();
|
|
expect(body.error).toBe('Invalid request body');
|
|
expect(JSON.stringify(body.issues)).toContain('value');
|
|
});
|
|
});
|
|
|
|
// ── DELETE /api/vault/:name ──────────────────────────────────────
|
|
|
|
describe('DELETE /api/vault/:name', () => {
|
|
it('deletes an existing secret', async () => {
|
|
vault.set('TO_DELETE', 'doomed');
|
|
|
|
const res = await server.inject({
|
|
method: 'DELETE',
|
|
url: '/api/vault/TO_DELETE',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.deleted).toBe(true);
|
|
expect(body.name).toBe('TO_DELETE');
|
|
|
|
// Verify deletion
|
|
expect(vault.has('TO_DELETE')).toBe(false);
|
|
});
|
|
|
|
it('returns deleted:false for non-existent secret', async () => {
|
|
const res = await server.inject({
|
|
method: 'DELETE',
|
|
url: '/api/vault/NONEXISTENT',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
const body = res.json();
|
|
expect(body.error).toBe('Secret not found');
|
|
expect(body.name).toBe('NONEXISTENT');
|
|
});
|
|
});
|
|
|
|
// ── POST /api/vault/:name/reveal ─────────────────────────────────
|
|
|
|
describe('POST /api/vault/:name/reveal', () => {
|
|
it('reveals the decrypted value', async () => {
|
|
vault.set('SECRET_KEY', 'super-secret-value', { credentialType: 'api_key' });
|
|
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault/SECRET_KEY/reveal',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.name).toBe('SECRET_KEY');
|
|
expect(body.value).toBe('super-secret-value');
|
|
expect(body.type).toBe('api_key');
|
|
});
|
|
|
|
it('returns default type when no credentialType metadata', async () => {
|
|
vault.set('PLAIN_KEY', 'plain-value');
|
|
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault/PLAIN_KEY/reveal',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.type).toBe('api_key');
|
|
});
|
|
|
|
it('returns 404 for non-existent secret', async () => {
|
|
const res = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/vault/NONEXISTENT/reveal',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
expect(res.json().error).toBe('Secret not found');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── Module Export Check ──────────────────────────────────────────────
|
|
|
|
describe('Vault Routes Module', () => {
|
|
it('exports vaultRoutes function', async () => {
|
|
const mod = await import('../../src/local/routes/vault.js');
|
|
expect(mod.vaultRoutes).toBeDefined();
|
|
expect(typeof mod.vaultRoutes).toBe('function');
|
|
});
|
|
});
|