mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 21:17:42 +02:00
test(byok): add 35 unit tests + update docs to as-built status
Three new test suites covering the critical BYOK paths: Pricing (14 tests): estimateCost for known/unknown models, scaling, formatCost edge cases, coverage check for all model IDs. ByokBackend (10 tests): tier identification, resolver behavior, provider dispatch, parameter passthrough, onUsage callback, error paths (no key, unregistered provider), invalidateAvailability. ByokVault (11 tests): encryption at rest verification, decryption round-trip, auto-default for first key, promoting default demotes previous, getForProvider logic, listMeta excludes apiKey, soft delete, recordUsage accumulation, cross-provider isolation. Updates docs/architecture/BYOK_PLAN.md with as-built status — phase table with commit references, deviations from original plan (no server-proxy fallback, no sensitive opt-in UI, no per-task provider override yet), test coverage matrix, troubleshooting guide, v2 follow-ups. Provider adapters remain unit-untested (need fetch mocking + SSE parsing) — smoke tests only. Total: 35/35 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c6567a815
commit
e4f0a410d1
4 changed files with 614 additions and 23 deletions
190
apps/mana/apps/web/src/lib/byok/vault.test.ts
Normal file
190
apps/mana/apps/web/src/lib/byok/vault.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* ByokVault tests — encrypted key CRUD in IndexedDB.
|
||||
*
|
||||
* Uses fake-indexeddb and a real AES-GCM key from SubtleCrypto.
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
|
||||
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// Placeholder key, replaced in beforeAll
|
||||
let testKey: CryptoKey | null = null;
|
||||
|
||||
vi.mock('$lib/data/crypto/key-provider', () => ({
|
||||
getActiveKey: () => testKey,
|
||||
isVaultUnlocked: () => testKey !== null,
|
||||
}));
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { byokVault } from './vault';
|
||||
|
||||
beforeAll(async () => {
|
||||
testKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.table('_byokKeys').clear();
|
||||
});
|
||||
|
||||
describe('ByokVault CRUD', () => {
|
||||
it('creates a key encrypted at rest', async () => {
|
||||
const key = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Test Key',
|
||||
apiKey: 'sk-verysecret123',
|
||||
});
|
||||
|
||||
expect(key.id).toBeTruthy();
|
||||
expect(key.apiKey).toBe('sk-verysecret123');
|
||||
|
||||
const raw = await db.table('_byokKeys').get(key.id);
|
||||
expect(raw.apiKeyEncrypted).not.toBe('sk-verysecret123');
|
||||
expect(JSON.stringify(raw.apiKeyEncrypted)).not.toContain('sk-verysecret123');
|
||||
});
|
||||
|
||||
it('decrypts correctly on read', async () => {
|
||||
await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Test',
|
||||
apiKey: 'sk-abc123',
|
||||
});
|
||||
|
||||
const all = await byokVault.listAll();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].apiKey).toBe('sk-abc123');
|
||||
});
|
||||
|
||||
it('first key for a provider becomes default automatically', async () => {
|
||||
const k1 = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'First',
|
||||
apiKey: 'sk-1',
|
||||
});
|
||||
expect(k1.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('promoting a key to default demotes the previous default', async () => {
|
||||
const k1 = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'First',
|
||||
apiKey: 'sk-1',
|
||||
});
|
||||
const k2 = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Second',
|
||||
apiKey: 'sk-2',
|
||||
isDefault: false,
|
||||
});
|
||||
expect(k1.isDefault).toBe(true);
|
||||
|
||||
await byokVault.update(k2.id, { isDefault: true });
|
||||
|
||||
const meta = await byokVault.listMeta();
|
||||
const first = meta.find((k) => k.id === k1.id)!;
|
||||
const second = meta.find((k) => k.id === k2.id)!;
|
||||
expect(first.isDefault).toBe(false);
|
||||
expect(second.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('getForProvider returns default if set', async () => {
|
||||
await byokVault.create({ provider: 'anthropic', label: 'A', apiKey: 'k1' });
|
||||
await byokVault.create({
|
||||
provider: 'anthropic',
|
||||
label: 'B',
|
||||
apiKey: 'k2',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const found = await byokVault.getForProvider('anthropic');
|
||||
expect(found?.label).toBe('A');
|
||||
expect(found?.apiKey).toBe('k1');
|
||||
});
|
||||
|
||||
it('getForProvider returns null when no keys for provider', async () => {
|
||||
await byokVault.create({ provider: 'openai', label: 'A', apiKey: 'k' });
|
||||
const found = await byokVault.getForProvider('anthropic');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('listMeta does NOT decrypt the api key', async () => {
|
||||
await byokVault.create({ provider: 'openai', label: 'Test', apiKey: 'sk-secret' });
|
||||
const meta = await byokVault.listMeta();
|
||||
expect(meta[0]).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
it('delete is soft', async () => {
|
||||
const k = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Test',
|
||||
apiKey: 'sk',
|
||||
});
|
||||
await byokVault.delete(k.id);
|
||||
|
||||
const meta = await byokVault.listMeta();
|
||||
expect(meta).toHaveLength(0);
|
||||
|
||||
const raw = await db.table('_byokKeys').get(k.id);
|
||||
expect(raw).toBeDefined();
|
||||
expect(raw.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('update changes label and model', async () => {
|
||||
const k = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Old',
|
||||
apiKey: 'sk',
|
||||
model: 'gpt-4o',
|
||||
});
|
||||
await byokVault.update(k.id, { label: 'New', model: 'gpt-5' });
|
||||
|
||||
const meta = await byokVault.listMeta();
|
||||
expect(meta[0].label).toBe('New');
|
||||
expect(meta[0].model).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('recordUsage increments counters', async () => {
|
||||
const k = await byokVault.create({
|
||||
provider: 'openai',
|
||||
label: 'Test',
|
||||
apiKey: 'sk',
|
||||
});
|
||||
|
||||
await byokVault.recordUsage(k.id, 100, 0.015);
|
||||
await byokVault.recordUsage(k.id, 50, 0.008);
|
||||
|
||||
const meta = await byokVault.listMeta();
|
||||
expect(meta[0].usageCount).toBe(2);
|
||||
expect(meta[0].totalTokens).toBe(150);
|
||||
expect(meta[0].totalCostUsd).toBeCloseTo(0.023, 6);
|
||||
expect(meta[0].lastUsedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles multiple providers independently', async () => {
|
||||
await byokVault.create({ provider: 'openai', label: 'OpenAI', apiKey: 'sk-oai' });
|
||||
await byokVault.create({
|
||||
provider: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
apiKey: 'sk-ant',
|
||||
});
|
||||
await byokVault.create({ provider: 'gemini', label: 'Gemini', apiKey: 'g-key' });
|
||||
|
||||
const openai = await byokVault.getForProvider('openai');
|
||||
const anthropic = await byokVault.getForProvider('anthropic');
|
||||
const gemini = await byokVault.getForProvider('gemini');
|
||||
const mistral = await byokVault.getForProvider('mistral');
|
||||
|
||||
expect(openai?.apiKey).toBe('sk-oai');
|
||||
expect(anthropic?.apiKey).toBe('sk-ant');
|
||||
expect(gemini?.apiKey).toBe('g-key');
|
||||
expect(mistral).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue