This commit is contained in:
254
packages/core/tests/compliance/template-store.test.ts
Normal file
254
packages/core/tests/compliance/template-store.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* ComplianceTemplateStore unit tests (M-03)
|
||||
*
|
||||
* Covers CRUD + section merge semantics + default section fill-in + risk-class CHECK
|
||||
* constraint + deletion. Following HarvestRunStore test shape (in-memory MindDB).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { ComplianceTemplateStore, KVARK_TEMPLATE_NAME } from '../../src/compliance/template-store.js';
|
||||
import type { ComplianceTemplateSections } from '../../src/compliance/types.js';
|
||||
|
||||
const ALL_ON: ComplianceTemplateSections = {
|
||||
interactions: true,
|
||||
oversight: true,
|
||||
models: true,
|
||||
provenance: true,
|
||||
riskAssessment: true,
|
||||
fria: true,
|
||||
};
|
||||
const ALL_OFF: ComplianceTemplateSections = {
|
||||
interactions: false,
|
||||
oversight: false,
|
||||
models: false,
|
||||
provenance: false,
|
||||
riskAssessment: false,
|
||||
fria: false,
|
||||
};
|
||||
|
||||
describe('ComplianceTemplateStore', () => {
|
||||
let db: MindDB;
|
||||
let store: ComplianceTemplateStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
store = new ComplianceTemplateStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('persists a template with all fields', () => {
|
||||
const t = store.create({
|
||||
name: 'KVARK enterprise',
|
||||
description: 'Full AI Act package for enterprise on-prem',
|
||||
sections: ALL_ON,
|
||||
riskClassification: 'high-risk',
|
||||
orgName: 'KVARK Sovereign',
|
||||
footerText: 'Confidential — internal only',
|
||||
});
|
||||
expect(t.id).toBeGreaterThan(0);
|
||||
expect(t.name).toBe('KVARK enterprise');
|
||||
expect(t.description).toBe('Full AI Act package for enterprise on-prem');
|
||||
expect(t.sections).toEqual(ALL_ON);
|
||||
expect(t.riskClassification).toBe('high-risk');
|
||||
expect(t.orgName).toBe('KVARK Sovereign');
|
||||
expect(t.footerText).toBe('Confidential — internal only');
|
||||
expect(t.createdAt).toBeTruthy();
|
||||
expect(t.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('defaults optional fields to null', () => {
|
||||
const t = store.create({ name: 'Bare', sections: ALL_OFF });
|
||||
expect(t.description).toBeNull();
|
||||
expect(t.riskClassification).toBeNull();
|
||||
expect(t.orgName).toBeNull();
|
||||
expect(t.footerText).toBeNull();
|
||||
});
|
||||
|
||||
it('trims whitespace from name', () => {
|
||||
const t = store.create({ name: ' Spaced ', sections: ALL_OFF });
|
||||
expect(t.name).toBe('Spaced');
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(() => store.create({ name: ' ', sections: ALL_OFF })).toThrow(/name is required/i);
|
||||
});
|
||||
|
||||
it('fills in missing section keys with defaults', () => {
|
||||
const t = store.create({
|
||||
name: 'Partial',
|
||||
// Only interactions specified; others should fall back to DEFAULT_SECTIONS.
|
||||
sections: { interactions: false } as ComplianceTemplateSections,
|
||||
});
|
||||
expect(t.sections.interactions).toBe(false);
|
||||
expect(t.sections.oversight).toBe(true); // default
|
||||
expect(t.sections.fria).toBe(false); // default
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns null for missing id', () => {
|
||||
expect(store.getById(999)).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a created template', () => {
|
||||
const a = store.create({ name: 'A', sections: ALL_ON, riskClassification: 'limited' });
|
||||
const b = store.getById(a.id);
|
||||
expect(b).not.toBeNull();
|
||||
expect(b?.name).toBe('A');
|
||||
expect(b?.sections).toEqual(ALL_ON);
|
||||
expect(b?.riskClassification).toBe('limited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns empty array when none exist', () => {
|
||||
expect(store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns templates newest-updated first', async () => {
|
||||
store.create({ name: 'Old', sections: ALL_OFF });
|
||||
await new Promise(resolve => setTimeout(resolve, 1100)); // datetime('now') is second-resolution
|
||||
store.create({ name: 'New', sections: ALL_OFF });
|
||||
const list = store.list();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].name).toBe('New');
|
||||
expect(list[1].name).toBe('Old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('returns null for missing id', () => {
|
||||
expect(store.update(999, { name: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates only the fields provided', () => {
|
||||
const t = store.create({
|
||||
name: 'Orig',
|
||||
description: 'desc',
|
||||
sections: ALL_ON,
|
||||
orgName: 'Acme',
|
||||
});
|
||||
const updated = store.update(t.id, { name: 'Renamed' });
|
||||
expect(updated?.name).toBe('Renamed');
|
||||
expect(updated?.description).toBe('desc'); // preserved
|
||||
expect(updated?.sections).toEqual(ALL_ON); // preserved
|
||||
expect(updated?.orgName).toBe('Acme'); // preserved
|
||||
});
|
||||
|
||||
it('clears a field when explicitly set to null', () => {
|
||||
const t = store.create({
|
||||
name: 'T',
|
||||
sections: ALL_ON,
|
||||
orgName: 'To remove',
|
||||
footerText: 'also remove',
|
||||
});
|
||||
const updated = store.update(t.id, { orgName: null, footerText: null });
|
||||
expect(updated?.orgName).toBeNull();
|
||||
expect(updated?.footerText).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces sections wholesale', () => {
|
||||
const t = store.create({ name: 'T', sections: ALL_ON });
|
||||
const updated = store.update(t.id, { sections: ALL_OFF });
|
||||
expect(updated?.sections).toEqual(ALL_OFF);
|
||||
});
|
||||
|
||||
it('rejects an empty rename', () => {
|
||||
const t = store.create({ name: 'T', sections: ALL_OFF });
|
||||
expect(() => store.update(t.id, { name: ' ' })).toThrow(/name is required/i);
|
||||
});
|
||||
|
||||
it('bumps updated_at', async () => {
|
||||
const t = store.create({ name: 'T', sections: ALL_OFF });
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
const updated = store.update(t.id, { name: 'T2' });
|
||||
expect(updated?.updatedAt).not.toBe(t.updatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('returns false for missing id', () => {
|
||||
expect(store.delete(999)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes an existing row and returns true', () => {
|
||||
const t = store.create({ name: 'T', sections: ALL_OFF });
|
||||
expect(store.delete(t.id)).toBe(true);
|
||||
expect(store.getById(t.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk_classification CHECK constraint', () => {
|
||||
it('accepts all four AIActRiskLevel values', () => {
|
||||
for (const level of ['minimal', 'limited', 'high-risk', 'unacceptable'] as const) {
|
||||
const t = store.create({ name: `T-${level}`, sections: ALL_OFF, riskClassification: level });
|
||||
expect(t.riskClassification).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an invalid risk class at the SQL layer', () => {
|
||||
expect(() =>
|
||||
store.create({
|
||||
name: 'Bad',
|
||||
sections: ALL_OFF,
|
||||
riskClassification: 'super-dangerous' as never,
|
||||
}),
|
||||
).toThrow(); // better-sqlite3 surfaces the CHECK violation
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedKvarkTemplateIfMissing (M-06)', () => {
|
||||
it('creates the KVARK template on first run', () => {
|
||||
const seeded = store.seedKvarkTemplateIfMissing();
|
||||
expect(seeded).not.toBeNull();
|
||||
expect(seeded!.name).toBe(KVARK_TEMPLATE_NAME);
|
||||
expect(seeded!.riskClassification).toBe('high-risk');
|
||||
expect(seeded!.sections.fria).toBe(true);
|
||||
expect(seeded!.orgName).toContain('KVARK');
|
||||
expect(seeded!.footerText).toContain('sovereign');
|
||||
});
|
||||
|
||||
it('is idempotent — second call returns null', () => {
|
||||
store.seedKvarkTemplateIfMissing();
|
||||
expect(store.seedKvarkTemplateIfMissing()).toBeNull();
|
||||
// Only one KVARK row exists.
|
||||
const all = store.list().filter(t => t.name === KVARK_TEMPLATE_NAME);
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not seed when the user already created a same-named template', () => {
|
||||
store.create({
|
||||
name: KVARK_TEMPLATE_NAME,
|
||||
sections: ALL_OFF,
|
||||
});
|
||||
expect(store.seedKvarkTemplateIfMissing()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSections (static)', () => {
|
||||
it('unions template + runtime flags', () => {
|
||||
const template: ComplianceTemplateSections = { ...ALL_OFF, interactions: true, fria: true };
|
||||
const runtime: ComplianceTemplateSections = { ...ALL_OFF, oversight: true };
|
||||
const merged = ComplianceTemplateStore.mergeSections(template, runtime);
|
||||
expect(merged.interactions).toBe(true); // from template
|
||||
expect(merged.oversight).toBe(true); // from runtime
|
||||
expect(merged.fria).toBe(true); // from template
|
||||
expect(merged.models).toBe(false);
|
||||
});
|
||||
|
||||
it('never hides a section the runtime requested (union semantics)', () => {
|
||||
const merged = ComplianceTemplateStore.mergeSections(ALL_OFF, ALL_ON);
|
||||
expect(merged).toEqual(ALL_ON);
|
||||
});
|
||||
|
||||
it('never hides a section the template requested', () => {
|
||||
const merged = ComplianceTemplateStore.mergeSections(ALL_ON, ALL_OFF);
|
||||
expect(merged).toEqual(ALL_ON);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
packages/core/tests/config.test.ts
Normal file
245
packages/core/tests/config.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { WaggleConfig, type ProviderEntry, type TeamServerConfig } from '../src/config.js';
|
||||
|
||||
describe('WaggleConfig', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-config-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('creates config directory if missing', () => {
|
||||
const base = makeTempDir();
|
||||
const configDir = path.join(base, 'nested', '.waggle');
|
||||
|
||||
new WaggleConfig(configDir);
|
||||
|
||||
expect(fs.existsSync(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns default config when no file exists', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
expect(config.getDefaultModel()).toBe('claude-sonnet-4-6');
|
||||
expect(config.getProviders()).toEqual({});
|
||||
expect(config.getMindPath()).toBe(path.join(configDir, 'default.mind'));
|
||||
});
|
||||
|
||||
it('saves and loads provider config', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
const provider: ProviderEntry = {
|
||||
apiKey: 'sk-test-key',
|
||||
models: ['claude-sonnet-4-6', 'claude-haiku-3'],
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
};
|
||||
|
||||
config.setProvider('anthropic', provider);
|
||||
config.save();
|
||||
|
||||
// Load fresh instance from same directory
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
const providers = config2.getProviders();
|
||||
|
||||
expect(providers['anthropic']).toEqual(provider);
|
||||
expect(providers['anthropic'].apiKey).toBe('sk-test-key');
|
||||
expect(providers['anthropic'].models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sets and gets default model', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
config.setDefaultModel('gpt-4o');
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
expect(config2.getDefaultModel()).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
it('returns mind file path', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
expect(config.getMindPath()).toBe(path.join(configDir, 'default.mind'));
|
||||
});
|
||||
|
||||
it('removes a provider', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
config.setProvider('anthropic', { apiKey: 'key1', models: ['m1'] });
|
||||
config.setProvider('openai', { apiKey: 'key2', models: ['m2'] });
|
||||
config.removeProvider('anthropic');
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
const providers = config2.getProviders();
|
||||
expect(providers['anthropic']).toBeUndefined();
|
||||
expect(providers['openai']).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns config directory path', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
expect(config.getConfigDir()).toBe(configDir);
|
||||
});
|
||||
|
||||
describe('team server config', () => {
|
||||
it('returns null when no team server configured', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
expect(config.getTeamServer()).toBeNull();
|
||||
expect(config.isTeamConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('sets and gets team server config', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
const teamConfig: TeamServerConfig = {
|
||||
url: 'https://team.waggle.dev',
|
||||
token: 'clerk-jwt-token',
|
||||
userId: 'user-123',
|
||||
displayName: 'Marko',
|
||||
};
|
||||
|
||||
config.setTeamServer(teamConfig);
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
const loaded = config2.getTeamServer();
|
||||
expect(loaded).toEqual(teamConfig);
|
||||
expect(config2.isTeamConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('clears team server config', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
config.setTeamServer({ url: 'https://team.waggle.dev' });
|
||||
config.clearTeamServer();
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
expect(config2.getTeamServer()).toBeNull();
|
||||
expect(config2.isTeamConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('persists team server through save/load cycle', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
config.setTeamServer({ url: 'https://example.com', userId: 'u1' });
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
expect(config2.getTeamServer()!.url).toBe('https://example.com');
|
||||
expect(config2.getTeamServer()!.userId).toBe('u1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('governed CLI config', () => {
|
||||
it('normalizes, deduplicates, and persists the CLI allowlist', () => {
|
||||
const configDir = makeTempDir();
|
||||
const config = new WaggleConfig(configDir);
|
||||
|
||||
config.setCliAllowlist([' node ', 'NODE', '', 'git']);
|
||||
expect(config.getCliAllowlist()).toEqual(['node', 'git']);
|
||||
config.save();
|
||||
|
||||
const config2 = new WaggleConfig(configDir);
|
||||
expect(config2.getCliAllowlist()).toEqual(['node', 'git']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Pilot config fields', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-config-pilot-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null for fallbackModel when not set', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
expect(config.getFallbackModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('persists fallbackModel', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
config.setFallbackModel('qwen/qwen3.6-plus:free');
|
||||
config.save();
|
||||
const config2 = new WaggleConfig(tmpDir);
|
||||
expect(config2.getFallbackModel()).toBe('qwen/qwen3.6-plus:free');
|
||||
});
|
||||
|
||||
it('returns null for budgetModel when not set', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
expect(config.getBudgetModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('persists budgetModel', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
config.setBudgetModel('deepseek/deepseek-chat-v3-0324:free');
|
||||
config.save();
|
||||
const config2 = new WaggleConfig(tmpDir);
|
||||
expect(config2.getBudgetModel()).toBe('deepseek/deepseek-chat-v3-0324:free');
|
||||
});
|
||||
|
||||
it('returns 0.8 as default budgetThreshold', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
expect(config.getBudgetThreshold()).toBe(0.8);
|
||||
});
|
||||
|
||||
it('persists budgetThreshold', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
config.setBudgetThreshold(0.6);
|
||||
config.save();
|
||||
const config2 = new WaggleConfig(tmpDir);
|
||||
expect(config2.getBudgetThreshold()).toBe(0.6);
|
||||
});
|
||||
|
||||
it('clearFallbackModel removes the field', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
config.setFallbackModel('test-model');
|
||||
config.save();
|
||||
config.clearFallbackModel();
|
||||
config.save();
|
||||
const config2 = new WaggleConfig(tmpDir);
|
||||
expect(config2.getFallbackModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 90 as default maxIterations', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
expect(config.getMaxIterations()).toBe(90);
|
||||
});
|
||||
|
||||
it('persists maxIterations', () => {
|
||||
const config = new WaggleConfig(tmpDir);
|
||||
config.setMaxIterations(50);
|
||||
config.save();
|
||||
const config2 = new WaggleConfig(tmpDir);
|
||||
expect(config2.getMaxIterations()).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
packages/core/tests/cron-store.test.ts
Normal file
268
packages/core/tests/cron-store.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { CronStore, type CreateScheduleInput, type SavePendingActionInput } from '../src/cron-store.js';
|
||||
|
||||
describe('CronStore', () => {
|
||||
let tmpDir: string;
|
||||
let db: MindDB;
|
||||
let store: CronStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cron-'));
|
||||
db = new MindDB(path.join(tmpDir, 'test.mind'));
|
||||
store = new CronStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeInput(overrides?: Partial<CreateScheduleInput>): CreateScheduleInput {
|
||||
return {
|
||||
name: 'Daily backup',
|
||||
cronExpr: '0 9 * * *', // every day at 9am
|
||||
jobType: 'memory_consolidation',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('create with valid cron computes next_run_at in the future', () => {
|
||||
const schedule = store.create(makeInput());
|
||||
expect(schedule.id).toBeGreaterThan(0);
|
||||
expect(schedule.name).toBe('Daily backup');
|
||||
expect(schedule.cron_expr).toBe('0 9 * * *');
|
||||
expect(schedule.job_type).toBe('memory_consolidation');
|
||||
expect(schedule.enabled).toBe(1);
|
||||
expect(schedule.next_run_at).toBeTruthy();
|
||||
// next_run_at should be in the future
|
||||
expect(new Date(schedule.next_run_at!).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('create with invalid cron expression throws', () => {
|
||||
expect(() => store.create(makeInput({ cronExpr: 'not a cron' }))).toThrow();
|
||||
});
|
||||
|
||||
it('create agent_task without workspaceId throws', () => {
|
||||
expect(() =>
|
||||
store.create(makeInput({ jobType: 'agent_task' })),
|
||||
).toThrow(/workspace/i);
|
||||
});
|
||||
|
||||
it('create loop job type round-trips with its job_config', () => {
|
||||
const schedule = store.create(makeInput({
|
||||
jobType: 'loop',
|
||||
jobConfig: { prompt: 'Summarize what changed in this workspace.' },
|
||||
}));
|
||||
expect(schedule.job_type).toBe('loop');
|
||||
expect(JSON.parse(schedule.job_config).prompt).toBe('Summarize what changed in this workspace.');
|
||||
});
|
||||
|
||||
it('create loop without workspaceId succeeds (loops can run on the personal mind)', () => {
|
||||
const schedule = store.create(makeInput({ jobType: 'loop' }));
|
||||
expect(schedule.job_type).toBe('loop');
|
||||
expect(schedule.workspace_id).toBeNull();
|
||||
});
|
||||
|
||||
it('create agent_task with workspaceId succeeds', () => {
|
||||
const schedule = store.create(makeInput({
|
||||
jobType: 'agent_task',
|
||||
workspaceId: 'ws-123',
|
||||
}));
|
||||
expect(schedule.job_type).toBe('agent_task');
|
||||
expect(schedule.workspace_id).toBe('ws-123');
|
||||
});
|
||||
|
||||
it('list returns schedules ordered by name', () => {
|
||||
store.create(makeInput({ name: 'Zebra task' }));
|
||||
store.create(makeInput({ name: 'Alpha task' }));
|
||||
store.create(makeInput({ name: 'Middle task' }));
|
||||
|
||||
const list = store.list();
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].name).toBe('Alpha task');
|
||||
expect(list[1].name).toBe('Middle task');
|
||||
expect(list[2].name).toBe('Zebra task');
|
||||
});
|
||||
|
||||
it('getById returns the schedule when found', () => {
|
||||
const created = store.create(makeInput({ name: 'Findable' }));
|
||||
const found = store.getById(created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('Findable');
|
||||
});
|
||||
|
||||
it('getById returns undefined when not found', () => {
|
||||
const found = store.getById(99999);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it('update changes name and enabled', () => {
|
||||
const created = store.create(makeInput());
|
||||
store.update(created.id, { name: 'Renamed', enabled: false });
|
||||
|
||||
const updated = store.getById(created.id)!;
|
||||
expect(updated.name).toBe('Renamed');
|
||||
expect(updated.enabled).toBe(0);
|
||||
});
|
||||
|
||||
it('update cronExpr recomputes next_run_at', () => {
|
||||
const created = store.create(makeInput({ cronExpr: '0 9 * * *' }));
|
||||
const originalNext = created.next_run_at;
|
||||
|
||||
// Change to every minute — next_run_at should change
|
||||
store.update(created.id, { cronExpr: '*/1 * * * *' });
|
||||
const updated = store.getById(created.id)!;
|
||||
expect(updated.cron_expr).toBe('*/1 * * * *');
|
||||
expect(updated.next_run_at).toBeTruthy();
|
||||
// The new next_run_at should differ (different schedule)
|
||||
// Both should be valid ISO dates
|
||||
expect(new Date(updated.next_run_at!).getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('delete removes the schedule', () => {
|
||||
const created = store.create(makeInput());
|
||||
store.delete(created.id);
|
||||
expect(store.getById(created.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getDue returns only enabled past-due schedules', () => {
|
||||
const raw = db.getDatabase();
|
||||
|
||||
// Create two schedules: one past-due, one future
|
||||
store.create(makeInput({ name: 'Future job' }));
|
||||
|
||||
// Manually insert a past-due schedule
|
||||
raw.prepare(`
|
||||
INSERT INTO cron_schedules (name, cron_expr, job_type, job_config, enabled, next_run_at, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, datetime('now', '-1 hour'), datetime('now'))
|
||||
`).run('Past due job', '0 9 * * *', 'memory_consolidation', '{}');
|
||||
|
||||
const due = store.getDue();
|
||||
expect(due).toHaveLength(1);
|
||||
expect(due[0].name).toBe('Past due job');
|
||||
});
|
||||
|
||||
it('getDue excludes disabled schedules', () => {
|
||||
const raw = db.getDatabase();
|
||||
|
||||
// Insert a past-due but disabled schedule
|
||||
raw.prepare(`
|
||||
INSERT INTO cron_schedules (name, cron_expr, job_type, job_config, enabled, next_run_at, created_at)
|
||||
VALUES (?, ?, ?, ?, 0, datetime('now', '-1 hour'), datetime('now'))
|
||||
`).run('Disabled job', '0 9 * * *', 'memory_consolidation', '{}');
|
||||
|
||||
const due = store.getDue();
|
||||
expect(due).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('markRun updates last_run_at and recomputes next_run_at', () => {
|
||||
const created = store.create(makeInput({ cronExpr: '0 9 * * *' }));
|
||||
expect(created.last_run_at).toBeNull();
|
||||
|
||||
store.markRun(created.id);
|
||||
const updated = store.getById(created.id)!;
|
||||
expect(updated.last_run_at).toBeTruthy();
|
||||
expect(updated.next_run_at).toBeTruthy();
|
||||
// next_run_at should be in the future
|
||||
expect(new Date(updated.next_run_at!).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('pruneExecutionHistory deletes only rows older than the cutoff', () => {
|
||||
const created = store.create(makeInput());
|
||||
// A fresh row (executed_at = now) must survive the prune.
|
||||
store.recordExecution(created.id, created.name, { success: true });
|
||||
// A back-dated row beyond the 30-day retention must go.
|
||||
db.getDatabase().prepare(`
|
||||
INSERT INTO cron_execution_history (schedule_id, schedule_name, executed_at, success)
|
||||
VALUES (?, ?, datetime('now', '-40 days'), 1)
|
||||
`).run(created.id, created.name);
|
||||
expect(store.getExecutionHistory(created.id)).toHaveLength(2);
|
||||
|
||||
const deleted = store.pruneExecutionHistory(30);
|
||||
expect(deleted).toBe(1);
|
||||
|
||||
const remaining = store.getExecutionHistory(created.id);
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].success).toBe(1);
|
||||
});
|
||||
|
||||
it('countExecutionsToday counts only today, only this schedule (#17 daily cap)', () => {
|
||||
const created = store.create(makeInput());
|
||||
const other = store.create(makeInput({ name: 'Other job' }));
|
||||
|
||||
store.recordExecution(created.id, created.name, { success: true });
|
||||
store.recordExecution(created.id, created.name, { success: false });
|
||||
store.recordExecution(other.id, other.name, { success: true });
|
||||
// Yesterday's row must not count.
|
||||
db.getDatabase().prepare(`
|
||||
INSERT INTO cron_execution_history (schedule_id, schedule_name, executed_at, success)
|
||||
VALUES (?, ?, datetime('now', '-1 day'), 1)
|
||||
`).run(created.id, created.name);
|
||||
|
||||
expect(store.countExecutionsToday(created.id)).toBe(2);
|
||||
expect(store.countExecutionsToday(other.id)).toBe(1);
|
||||
});
|
||||
|
||||
describe('pending_actions (L2 held-action queue)', () => {
|
||||
function held(over?: Partial<SavePendingActionInput>): SavePendingActionInput {
|
||||
return {
|
||||
id: 'pa-1', workspaceId: null, source: 'loop:1', toolName: 'send_email',
|
||||
argsJson: JSON.stringify({ to: 'x@y.z', subject: 'hi' }), summary: 'Send follow-up',
|
||||
riskLevel: 'medium', approvalClass: 'elevated', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
it('saves and lists held actions, filtered by status', () => {
|
||||
store.savePendingAction(held());
|
||||
store.savePendingAction(held({ id: 'pa-2', toolName: 'write_file' }));
|
||||
const heldRows = store.listPendingActions('held');
|
||||
expect(heldRows).toHaveLength(2);
|
||||
expect(heldRows.map(r => r.tool_name).sort()).toEqual(['send_email', 'write_file']);
|
||||
expect(store.listPendingActions('executed')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('claimPendingAction is an atomic idempotency gate (double-claim no-ops)', () => {
|
||||
store.savePendingAction(held());
|
||||
const first = store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:00:00Z');
|
||||
expect(first?.status).toBe('approved');
|
||||
expect(first?.decided_at).toBe('2026-06-29T10:00:00Z');
|
||||
// A second claim (double-approve / approve-after-deny) wins nothing.
|
||||
expect(store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:05:00Z')).toBeUndefined();
|
||||
expect(store.claimPendingAction('pa-1', 'denied', '2026-06-29T10:05:00Z')).toBeUndefined();
|
||||
// Unknown id → undefined.
|
||||
expect(store.claimPendingAction('nope', 'approved', '2026-06-29T10:00:00Z')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updatePendingActionResult records the terminal outcome after a claim', () => {
|
||||
store.savePendingAction(held());
|
||||
store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:00:00Z');
|
||||
store.updatePendingActionResult('pa-1', { status: 'executed', resultSummary: 'sent', executedAt: '2026-06-29T10:01:00Z' });
|
||||
const row = store.getPendingAction('pa-1');
|
||||
expect(row?.status).toBe('executed');
|
||||
expect(row?.result_summary).toBe('sent');
|
||||
expect(row?.executed_at).toBe('2026-06-29T10:01:00Z');
|
||||
});
|
||||
|
||||
it('persists held actions across a DB reopen (durable queue)', () => {
|
||||
store.savePendingAction(held());
|
||||
db.close();
|
||||
db = new MindDB(path.join(tmpDir, 'test.mind'));
|
||||
store = new CronStore(db);
|
||||
const rows = store.listPendingActions('held');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('pa-1');
|
||||
});
|
||||
|
||||
it('expireStalePendingActions flips past-due held rows to expired', () => {
|
||||
store.savePendingAction(held({ expiresAt: '2000-01-01T00:00:00Z' })); // long past
|
||||
store.savePendingAction(held({ id: 'pa-2', expiresAt: '2999-01-01T00:00:00Z' })); // future
|
||||
expect(store.expireStalePendingActions()).toBe(1);
|
||||
expect(store.listPendingActions('held').map(r => r.id)).toEqual(['pa-2']);
|
||||
expect(store.listPendingActions('expired').map(r => r.id)).toEqual(['pa-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/core/tests/embedding-provider-quota.test.ts
Normal file
267
packages/core/tests/embedding-provider-quota.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createEmbeddingProvider, EmbeddingQuotaExceededError, getMinimumTierForProvider } from '@waggle/hive-mind-core';
|
||||
import { TierError, TIER_CAPABILITIES } from '@waggle/shared';
|
||||
|
||||
describe('Embedding Provider — Tier & Quota Enforcement', () => {
|
||||
let tmpDir: string;
|
||||
let db: InstanceType<typeof Database>;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-embed-test-'));
|
||||
db = new Database(path.join(tmpDir, 'quota.db'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Tier enforcement on provider selection', () => {
|
||||
it('FREE user requesting litellm throws TierError (litellm stays Team-only)', async () => {
|
||||
// Solo (FREE) unlocks BYO cloud embeddings (voyage/openai) but NOT the
|
||||
// managed litellm router — that stays a paid-tier provider.
|
||||
await expect(
|
||||
createEmbeddingProvider({
|
||||
provider: 'litellm',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
})
|
||||
).rejects.toThrow(TierError);
|
||||
});
|
||||
|
||||
it('FREE user requesting inprocess succeeds', async () => {
|
||||
// inprocess may fail to load ONNX in test env, but should NOT throw TierError
|
||||
try {
|
||||
await createEmbeddingProvider({
|
||||
provider: 'inprocess',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
});
|
||||
} catch (err) {
|
||||
// If it fails, it should NOT be a TierError — it should be a probe failure
|
||||
expect(err).not.toBeInstanceOf(TierError);
|
||||
}
|
||||
});
|
||||
|
||||
it('FREE user requesting voyage does not throw TierError (Solo unlocks BYO cloud embeddings)', async () => {
|
||||
// voyage will fail to connect (no real API), but should NOT throw TierError —
|
||||
// Solo now allows the voyage/openai providers directly.
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'auto',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
});
|
||||
// Should fall back to mock (no real providers in test), but no TierError
|
||||
expect(provider.getActiveProvider()).toBeDefined();
|
||||
});
|
||||
|
||||
it('auto mode surfaces only key-backed providers for FREE tier', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'auto',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
});
|
||||
// FREE allows inprocess, mock, ollama + cloud (voyage/openai); cloud is
|
||||
// skipped here without API keys, so only local providers + mock surface.
|
||||
const status = provider.getStatus();
|
||||
for (const p of status.availableProviders) {
|
||||
expect(['inprocess', 'mock', 'ollama']).toContain(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quota enforcement', () => {
|
||||
// All current tiers have unlimited quotas (-1), so we patch FREE
|
||||
// to a finite quota for these tests to exercise the quota mechanism.
|
||||
const originalQuota = TIER_CAPABILITIES.FREE.embeddingQuotaPerMonth;
|
||||
|
||||
beforeEach(() => {
|
||||
(TIER_CAPABILITIES.FREE as { embeddingQuotaPerMonth: number }).embeddingQuotaPerMonth = 500;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(TIER_CAPABILITIES.FREE as { embeddingQuotaPerMonth: number }).embeddingQuotaPerMonth = originalQuota;
|
||||
});
|
||||
|
||||
it('FREE user at 499 embeddings succeeds', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'mock',
|
||||
userTier: 'FREE',
|
||||
userId: 'test-user',
|
||||
quotaDb: db,
|
||||
});
|
||||
|
||||
// Pre-fill 499 embeddings
|
||||
const ym = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
db.prepare('INSERT INTO embedding_usage (user_id, year_month, count, updated_at) VALUES (?, ?, 499, ?)').run('test-user', ym, Date.now());
|
||||
|
||||
// 500th should succeed (499 + 1 = 500 = quota)
|
||||
const result = await provider.embed('test text');
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
|
||||
it('FREE user at 500 embeddings throws EmbeddingQuotaExceededError', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'mock',
|
||||
userTier: 'FREE',
|
||||
userId: 'test-user',
|
||||
quotaDb: db,
|
||||
});
|
||||
|
||||
// Pre-fill to quota limit
|
||||
const ym = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
db.prepare('INSERT INTO embedding_usage (user_id, year_month, count, updated_at) VALUES (?, ?, 500, ?)').run('test-user', ym, Date.now());
|
||||
|
||||
await expect(provider.embed('test text')).rejects.toThrow(EmbeddingQuotaExceededError);
|
||||
});
|
||||
|
||||
it('TEAMS user with unlimited quota never throws quota error', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'mock',
|
||||
userTier: 'TEAMS',
|
||||
userId: 'test-user',
|
||||
quotaDb: db,
|
||||
});
|
||||
|
||||
// Even with high usage, should succeed (TEAMS has -1 = unlimited)
|
||||
const ym = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
db.prepare('INSERT INTO embedding_usage (user_id, year_month, count, updated_at) VALUES (?, ?, 999999, ?)').run('test-user', ym, Date.now());
|
||||
|
||||
const result = await provider.embed('test text');
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuotaStatus', () => {
|
||||
const originalQuota = TIER_CAPABILITIES.FREE.embeddingQuotaPerMonth;
|
||||
|
||||
beforeEach(() => {
|
||||
(TIER_CAPABILITIES.FREE as { embeddingQuotaPerMonth: number }).embeddingQuotaPerMonth = 500;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(TIER_CAPABILITIES.FREE as { embeddingQuotaPerMonth: number }).embeddingQuotaPerMonth = originalQuota;
|
||||
});
|
||||
|
||||
it('returns correct percentage for FREE user', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'mock',
|
||||
userTier: 'FREE',
|
||||
userId: 'test-user',
|
||||
quotaDb: db,
|
||||
});
|
||||
|
||||
// Use 250 of 500 quota
|
||||
const ym = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
db.prepare('INSERT INTO embedding_usage (user_id, year_month, count, updated_at) VALUES (?, ?, 250, ?)').run('test-user', ym, Date.now());
|
||||
|
||||
const status = provider.getQuotaStatus();
|
||||
expect(status.tier).toBe('FREE');
|
||||
expect(status.quota).toBe(500);
|
||||
expect(status.used).toBe(250);
|
||||
expect(status.remaining).toBe(250);
|
||||
expect(status.percentage).toBe(50);
|
||||
expect(status.resetsAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns unlimited for TEAMS tier', async () => {
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'mock',
|
||||
userTier: 'TEAMS',
|
||||
userId: 'test-user',
|
||||
quotaDb: db,
|
||||
});
|
||||
|
||||
const status = provider.getQuotaStatus();
|
||||
expect(status.tier).toBe('TEAMS');
|
||||
expect(status.quota).toBe(-1);
|
||||
expect(status.remaining).toBe(-1);
|
||||
expect(status.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WAGGLE_EVAL_MODE tier bypass (PA v5 §11.3)', () => {
|
||||
// Defensive cleanup: never leak the flag across tests. If a prior run
|
||||
// crashed mid-test, this block restores a known-clean baseline.
|
||||
beforeEach(() => { delete process.env.WAGGLE_EVAL_MODE; });
|
||||
afterEach(() => { delete process.env.WAGGLE_EVAL_MODE; });
|
||||
|
||||
it('without WAGGLE_EVAL_MODE: FREE + litellm still throws TierError (control)', async () => {
|
||||
// Explicit sanity check that the normal gate is still live — baseline
|
||||
// for the bypass test below. litellm is the provider FREE still lacks.
|
||||
expect(process.env.WAGGLE_EVAL_MODE).toBeUndefined();
|
||||
await expect(
|
||||
createEmbeddingProvider({
|
||||
provider: 'litellm',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
})
|
||||
).rejects.toThrow(TierError);
|
||||
});
|
||||
|
||||
it('with WAGGLE_EVAL_MODE=1: FREE + litellm no longer throws TierError', async () => {
|
||||
process.env.WAGGLE_EVAL_MODE = '1';
|
||||
try {
|
||||
await createEmbeddingProvider({
|
||||
provider: 'litellm',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
});
|
||||
} catch (err) {
|
||||
// Probe failure (no real voyage backend in tests) is fine — just not
|
||||
// a TierError. The point: the tier gate is bypassed.
|
||||
expect(err).not.toBeInstanceOf(TierError);
|
||||
}
|
||||
});
|
||||
|
||||
it('with WAGGLE_EVAL_MODE=1 and no userTier set: behaves as if unenforced', async () => {
|
||||
process.env.WAGGLE_EVAL_MODE = '1';
|
||||
const provider = await createEmbeddingProvider({
|
||||
provider: 'auto',
|
||||
quotaDb: db,
|
||||
});
|
||||
// Falls back to mock with no tier skip/probe; provider is constructed.
|
||||
expect(provider.getActiveProvider()).toBeDefined();
|
||||
});
|
||||
|
||||
it('only activates when env value is exactly "1" (defensive literal match)', async () => {
|
||||
// Guard against accidental truthy-but-not-"1" values. Harness must use
|
||||
// "1" exactly per §11.3.
|
||||
for (const bad of ['true', 'yes', '0', '']) {
|
||||
process.env.WAGGLE_EVAL_MODE = bad;
|
||||
await expect(
|
||||
createEmbeddingProvider({
|
||||
provider: 'litellm',
|
||||
userTier: 'FREE',
|
||||
quotaDb: db,
|
||||
})
|
||||
).rejects.toThrow(TierError);
|
||||
delete process.env.WAGGLE_EVAL_MODE;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinimumTierForProvider', () => {
|
||||
it('inprocess requires TRIAL (first tier that allows it)', () => {
|
||||
expect(getMinimumTierForProvider('inprocess')).toBe('TRIAL');
|
||||
});
|
||||
|
||||
it('voyage requires TRIAL (TRIAL unlocks all providers)', () => {
|
||||
// TRIAL is the first tier in TIERS array and has all providers
|
||||
expect(getMinimumTierForProvider('voyage')).toBe('TRIAL');
|
||||
});
|
||||
|
||||
it('litellm requires TRIAL', () => {
|
||||
// TRIAL is the first tier with litellm (all unlocked)
|
||||
expect(getMinimumTierForProvider('litellm')).toBe('TRIAL');
|
||||
});
|
||||
|
||||
it('mock requires TRIAL', () => {
|
||||
expect(getMinimumTierForProvider('mock')).toBe('TRIAL');
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/core/tests/file-indexer.test.ts
Normal file
267
packages/core/tests/file-indexer.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* FileIndexer unit tests (L-20)
|
||||
*
|
||||
* Covers: format gate, indexing, overwrite semantics, move, remove, truncation,
|
||||
* shared-content dedup safety, and the underlying file_index table.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { FrameStore } from '@waggle/hive-mind-core';
|
||||
import { FileIndexer, MAX_CONTENT_BYTES } from '../src/file-indexer.js';
|
||||
|
||||
describe('FileIndexer', () => {
|
||||
let db: MindDB;
|
||||
let indexer: FileIndexer;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
indexer = new FileIndexer(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('shouldIndex', () => {
|
||||
it('accepts markdown + text', () => {
|
||||
expect(FileIndexer.shouldIndex('/notes/a.md')).toBe(true);
|
||||
expect(FileIndexer.shouldIndex('/notes/a.markdown')).toBe(true);
|
||||
expect(FileIndexer.shouldIndex('/notes/a.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores case differences in the extension', () => {
|
||||
expect(FileIndexer.shouldIndex('/a.MD')).toBe(true);
|
||||
expect(FileIndexer.shouldIndex('/a.TXT')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects formats deferred to Bucket 2', () => {
|
||||
expect(FileIndexer.shouldIndex('/a.pdf')).toBe(false);
|
||||
expect(FileIndexer.shouldIndex('/a.docx')).toBe(false);
|
||||
expect(FileIndexer.shouldIndex('/a.xlsx')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects files with no extension', () => {
|
||||
expect(FileIndexer.shouldIndex('/a')).toBe(false);
|
||||
expect(FileIndexer.shouldIndex('/README')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexFile', () => {
|
||||
it('returns unsupported_format for non-indexable extensions', () => {
|
||||
const result = indexer.indexFile('/a.pdf', Buffer.from('PDF content'));
|
||||
expect(result.skipped).toBe(true);
|
||||
if (result.skipped) expect(result.reason).toBe('unsupported_format');
|
||||
});
|
||||
|
||||
it('indexes a markdown file and creates a backing frame', () => {
|
||||
const content = Buffer.from('# Hello\n\nThis is a note.');
|
||||
const result = indexer.indexFile('/notes/hello.md', content, 'text/markdown');
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
if (!result.skipped) {
|
||||
expect(result.frameId).toBeGreaterThan(0);
|
||||
expect(result.truncated).toBe(false);
|
||||
|
||||
const frames = new FrameStore(db);
|
||||
const frame = frames.getById(result.frameId);
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame!.content).toContain('# Hello');
|
||||
expect(frame!.content).toContain('[FILE: /notes/hello.md');
|
||||
expect(frame!.content).toContain('text/markdown');
|
||||
expect(frame!.source).toBe('system');
|
||||
}
|
||||
});
|
||||
|
||||
it('records the index row with hash + size + mime', () => {
|
||||
const content = Buffer.from('hello world');
|
||||
indexer.indexFile('/a.txt', content, 'text/plain');
|
||||
const row = indexer.getRow('/a.txt');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.sizeBytes).toBe(content.length);
|
||||
expect(row!.mimeType).toBe('text/plain');
|
||||
expect(row!.contentHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(row!.indexedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns unchanged when re-indexed with identical content', () => {
|
||||
const content = Buffer.from('same bytes');
|
||||
const first = indexer.indexFile('/a.md', content);
|
||||
expect(first.skipped).toBe(false);
|
||||
|
||||
const second = indexer.indexFile('/a.md', content);
|
||||
expect(second.skipped).toBe(true);
|
||||
if (second.skipped) expect(second.reason).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('swaps the frame on content change (overwrite path)', () => {
|
||||
const first = indexer.indexFile('/a.md', Buffer.from('original'));
|
||||
const second = indexer.indexFile('/a.md', Buffer.from('updated'));
|
||||
expect(first.skipped).toBe(false);
|
||||
expect(second.skipped).toBe(false);
|
||||
if (!first.skipped && !second.skipped) {
|
||||
expect(second.frameId).not.toBe(first.frameId);
|
||||
|
||||
// Old frame is gone.
|
||||
const frames = new FrameStore(db);
|
||||
expect(frames.getById(first.frameId)).toBeUndefined();
|
||||
// New frame exists and row points at it.
|
||||
expect(frames.getById(second.frameId)).toBeTruthy();
|
||||
const row = indexer.getRow('/a.md');
|
||||
expect(row!.frameId).toBe(second.frameId);
|
||||
}
|
||||
});
|
||||
|
||||
it('truncates content over MAX_CONTENT_BYTES and sets the truncated flag', () => {
|
||||
const giant = Buffer.alloc(MAX_CONTENT_BYTES + 5000, 0x41); // lots of 'A'
|
||||
const result = indexer.indexFile('/big.md', giant);
|
||||
expect(result.skipped).toBe(false);
|
||||
if (!result.skipped) {
|
||||
expect(result.truncated).toBe(true);
|
||||
const frames = new FrameStore(db);
|
||||
const frame = frames.getById(result.frameId);
|
||||
expect(frame!.content).toContain('[…truncated');
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an empty file as remove-if-present', () => {
|
||||
indexer.indexFile('/a.md', Buffer.from('something'));
|
||||
expect(indexer.getRow('/a.md')).toBeTruthy();
|
||||
|
||||
const emptyResult = indexer.indexFile('/a.md', Buffer.from(''));
|
||||
expect(emptyResult.skipped).toBe(true);
|
||||
if (emptyResult.skipped) expect(emptyResult.reason).toBe('empty');
|
||||
expect(indexer.getRow('/a.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('rolls back atomically when a mutation throws mid-overwrite (L-20 BLOCKER-1)', () => {
|
||||
// Index a file, then simulate a crash during the overwrite path by
|
||||
// making frames.delete throw. The whole transaction (new frame +
|
||||
// old-frame delete + file_index UPDATE) must roll back together. Table
|
||||
// state after the throw must match state before the throw.
|
||||
const first = indexer.indexFile('/a.md', Buffer.from('original'));
|
||||
expect(first.skipped).toBe(false);
|
||||
if (first.skipped) return;
|
||||
const originalFrameId = first.frameId;
|
||||
|
||||
const raw = db.getDatabase();
|
||||
const framesBefore = (raw.prepare('SELECT COUNT(*) as c FROM memory_frames').get() as { c: number }).c;
|
||||
const indexBefore = (raw.prepare('SELECT COUNT(*) as c FROM file_index').get() as { c: number }).c;
|
||||
|
||||
// Inject crash mid-transaction: the old-frame delete step throws.
|
||||
const framesProp = (indexer as unknown as { frames: FrameStore }).frames;
|
||||
const deleteSpy = vi.spyOn(framesProp, 'delete').mockImplementation(() => {
|
||||
throw new Error('simulated crash mid-overwrite');
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => indexer.indexFile('/a.md', Buffer.from('updated'))).toThrow('simulated crash mid-overwrite');
|
||||
} finally {
|
||||
deleteSpy.mockRestore();
|
||||
}
|
||||
|
||||
// Rollback invariants:
|
||||
// 1. Frame-table row count unchanged (new frame not committed).
|
||||
// 2. Old frame still present (delete was rolled back).
|
||||
// 3. file_index row count unchanged + row still points at original frame.
|
||||
const framesAfter = (raw.prepare('SELECT COUNT(*) as c FROM memory_frames').get() as { c: number }).c;
|
||||
const indexAfter = (raw.prepare('SELECT COUNT(*) as c FROM file_index').get() as { c: number }).c;
|
||||
expect(framesAfter).toBe(framesBefore);
|
||||
expect(indexAfter).toBe(indexBefore);
|
||||
|
||||
const framesStore = new FrameStore(db);
|
||||
expect(framesStore.getById(originalFrameId)).toBeTruthy();
|
||||
expect(indexer.getRow('/a.md')!.frameId).toBe(originalFrameId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFile', () => {
|
||||
it('returns false when the path is not indexed', () => {
|
||||
expect(indexer.removeFile('/not-indexed.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes the frame + row when indexed', () => {
|
||||
const result = indexer.indexFile('/a.md', Buffer.from('goodbye'));
|
||||
expect(result.skipped).toBe(false);
|
||||
if (!result.skipped) {
|
||||
expect(indexer.removeFile('/a.md')).toBe(true);
|
||||
expect(indexer.getRow('/a.md')).toBeNull();
|
||||
const frames = new FrameStore(db);
|
||||
expect(frames.getById(result.frameId)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('two paths with identical bodies get distinct frames (path is in the header)', () => {
|
||||
// The header `[FILE: <path>]` is part of the frame content, so two files
|
||||
// with the same body but different paths produce different frame hashes.
|
||||
// This is intentional: a file is a file, not just its bytes.
|
||||
const body = Buffer.from('shared body');
|
||||
const first = indexer.indexFile('/dir-a/shared.md', body);
|
||||
const second = indexer.indexFile('/dir-b/shared.md', body);
|
||||
expect(first.skipped).toBe(false);
|
||||
expect(second.skipped).toBe(false);
|
||||
if (!first.skipped && !second.skipped) {
|
||||
expect(second.frameId).not.toBe(first.frameId);
|
||||
|
||||
// Removing one file removes its own frame + row without touching the other.
|
||||
indexer.removeFile('/dir-a/shared.md');
|
||||
const frames = new FrameStore(db);
|
||||
expect(frames.getById(first.frameId)).toBeUndefined();
|
||||
expect(frames.getById(second.frameId)).toBeTruthy();
|
||||
expect(indexer.getRow('/dir-b/shared.md')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveFile', () => {
|
||||
it('updates the recorded path + keeps the frame', () => {
|
||||
const res = indexer.indexFile('/old.md', Buffer.from('body'));
|
||||
expect(res.skipped).toBe(false);
|
||||
if (!res.skipped) {
|
||||
expect(indexer.moveFile('/old.md', '/new.md')).toBe(true);
|
||||
expect(indexer.getRow('/old.md')).toBeNull();
|
||||
const row = indexer.getRow('/new.md');
|
||||
expect(row!.frameId).toBe(res.frameId);
|
||||
|
||||
const frames = new FrameStore(db);
|
||||
expect(frames.getById(res.frameId)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false when the source is not indexed', () => {
|
||||
expect(indexer.moveFile('/missing.md', '/somewhere.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites a pre-existing destination index row', () => {
|
||||
indexer.indexFile('/src.md', Buffer.from('src-body'));
|
||||
indexer.indexFile('/dst.md', Buffer.from('dst-body'));
|
||||
expect(indexer.moveFile('/src.md', '/dst.md')).toBe(true);
|
||||
expect(indexer.getRow('/src.md')).toBeNull();
|
||||
expect(indexer.getRow('/dst.md')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAll', () => {
|
||||
it('returns an empty array with no index rows', () => {
|
||||
expect(indexer.listAll()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns rows newest-indexed first', async () => {
|
||||
indexer.indexFile('/a.md', Buffer.from('a'));
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
indexer.indexFile('/b.md', Buffer.from('b'));
|
||||
const all = indexer.listAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0].filePath).toBe('/b.md');
|
||||
expect(all[1].filePath).toBe('/a.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor side effects', () => {
|
||||
it('ensureTable is idempotent (second instance on same DB is OK)', () => {
|
||||
indexer.indexFile('/a.md', Buffer.from('x'));
|
||||
const indexer2 = new FileIndexer(db);
|
||||
expect(indexer2.getRow('/a.md')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
175
packages/core/tests/file-store-path-safety.test.ts
Normal file
175
packages/core/tests/file-store-path-safety.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { LocalFileStore, LinkedDirStore, isSensitiveFilePath } from '../src/file-store.js';
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsguard-')); });
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('isSensitiveFilePath', () => {
|
||||
it('flags SSH/GPG/cloud secret directories and key basenames', () => {
|
||||
for (const p of ['.ssh/id_rsa', '.ssh/config', '.aws/credentials', '.gnupg/secring.gpg',
|
||||
'id_rsa', 'id_ed25519', '.netrc', '.pgpass', '.git-credentials', 'credentials.json',
|
||||
'project/nested/.ssh/known_hosts']) {
|
||||
expect(isSensitiveFilePath(p), p).toBe(true);
|
||||
}
|
||||
});
|
||||
it('flags dotenv files but NOT their checked-in templates', () => {
|
||||
expect(isSensitiveFilePath('.env')).toBe(true);
|
||||
expect(isSensitiveFilePath('config/.env.production')).toBe(true);
|
||||
expect(isSensitiveFilePath('.env.example')).toBe(false);
|
||||
expect(isSensitiveFilePath('.env.template')).toBe(false);
|
||||
});
|
||||
it('is case-insensitive and path-separator agnostic', () => {
|
||||
expect(isSensitiveFilePath('.SSH/ID_RSA')).toBe(true);
|
||||
expect(isSensitiveFilePath('.ssh\\id_rsa')).toBe(true);
|
||||
});
|
||||
it('does NOT flag ordinary files (incl. public keys)', () => {
|
||||
for (const p of ['', 'readme.md', 'src/index.ts', 'config.json', 'id_rsa.pub', '.environment', 'data/credentials-form.tsx']) {
|
||||
expect(isSensitiveFilePath(p), p).toBe(false);
|
||||
}
|
||||
});
|
||||
it('defeats Windows ADS + trailing dot/space normalization tricks', () => {
|
||||
for (const p of ['id_rsa::$DATA', '.env::$DATA', 'id_rsa.', '.env ', '.NPMRC ']) {
|
||||
expect(isSensitiveFilePath(p), p).toBe(true);
|
||||
}
|
||||
});
|
||||
it('flags backup copies of secrets but not ordinary backups', () => {
|
||||
expect(isSensitiveFilePath('credentials.bak')).toBe(true);
|
||||
expect(isSensitiveFilePath('id_rsa.old')).toBe(true);
|
||||
expect(isSensitiveFilePath('.npmrc.backup')).toBe(true);
|
||||
expect(isSensitiveFilePath('readme.bak')).toBe(false);
|
||||
});
|
||||
it('flags extended secret classes (pem keys, authorized_keys, cloud, terraform)', () => {
|
||||
for (const p of ['deploy/secret.pem', 'authorized_keys', 'known_hosts', '.azure/accessTokens.json',
|
||||
'.terraform/x', 'service-account.json', 'terraform.tfstate', 'infra/terraform.tfstate.backup']) {
|
||||
expect(isSensitiveFilePath(p), p).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSafe containment (via LocalFileStore)', () => {
|
||||
const store = () => new LocalFileStore(tmp, 'ws1'); // root = tmp/workspaces/ws1/files
|
||||
|
||||
it('reads/writes a normal in-root path', async () => {
|
||||
const s = store();
|
||||
await s.writeFile('notes/todo.txt', 'hi');
|
||||
expect((await s.readFile('notes/todo.txt')).toString()).toBe('hi');
|
||||
});
|
||||
|
||||
it('denies a classic ../ escape', async () => {
|
||||
await expect(store().readFile('../../../etc/passwd')).rejects.toThrow(/traversal denied/i);
|
||||
});
|
||||
|
||||
it('denies a SIBLING-prefix escape (the startsWith bug)', async () => {
|
||||
// root is .../ws1/files; this resolves to a sibling .../ws1/files-evil which a
|
||||
// bare startsWith(root) check WRONGLY admitted. Segment-boundary check rejects it.
|
||||
await expect(store().writeFile('../files-evil/loot.txt', 'x')).rejects.toThrow(/traversal denied/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkedDirStore sensitive-file deny (external folder)', () => {
|
||||
function seedLinked(): string {
|
||||
const dir = path.join(tmp, 'project');
|
||||
fs.mkdirSync(path.join(dir, '.ssh'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, '.aws'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.ssh', 'id_rsa'), 'PRIVATE KEY');
|
||||
fs.writeFileSync(path.join(dir, '.aws', 'credentials'), '[default]\naws_secret=xxx');
|
||||
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
|
||||
fs.writeFileSync(path.join(dir, '.env.example'), 'SECRET=');
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), 'hello');
|
||||
fs.writeFileSync(path.join(dir, 'credentials.json'), '{"token":"x"}');
|
||||
fs.writeFileSync(path.join(dir, 'data.json'), '{"ok":true}');
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('denies reading SSH keys, cloud creds, and .env', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
await expect(s.readFile('.ssh/id_rsa')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(s.readFile('.aws/credentials')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(s.readFile('.env')).rejects.toThrow(/sensitive file denied/i);
|
||||
});
|
||||
|
||||
it('allows a normal file and the .env template', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
expect((await s.readFile('README.md')).toString()).toBe('hello');
|
||||
expect((await s.readFile('.env.example')).toString()).toBe('SECRET=');
|
||||
});
|
||||
|
||||
it('denies writing/clobbering and moving a secret file', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
await expect(s.writeFile('.ssh/authorized_keys', 'attacker-key')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(s.moveFile('README.md', '.env')).rejects.toThrow(/sensitive file denied/i);
|
||||
});
|
||||
|
||||
it('searchFiles never discloses a non-dot secret (credentials.json)', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
const names = (await s.searchFiles('*.json')).map(f => f.name);
|
||||
expect(names).toContain('data.json');
|
||||
expect(names).not.toContain('credentials.json');
|
||||
});
|
||||
|
||||
it('listFiles hides non-dot secrets (credentials.json) but keeps normal files', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
const names = (await s.listFiles()).map(f => f.name);
|
||||
expect(names).toContain('README.md');
|
||||
expect(names).not.toContain('credentials.json');
|
||||
});
|
||||
|
||||
it('searchFiles cannot escape the root via a ../ glob pattern', async () => {
|
||||
const outside = path.join(tmp, 'outside'); fs.mkdirSync(outside, { recursive: true });
|
||||
fs.writeFileSync(path.join(outside, 'loot.txt'), 'x');
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
const results = await s.searchFiles('../**/*');
|
||||
expect(results.every(f => !f.path.includes('..'))).toBe(true);
|
||||
expect(results.map(f => f.name)).not.toContain('loot.txt');
|
||||
});
|
||||
|
||||
it('still denies a ../ escape out of the linked root', async () => {
|
||||
const s = new LinkedDirStore(seedLinked());
|
||||
await expect(s.readFile('../../secret.txt')).rejects.toThrow(/traversal denied/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkedDirStore symlink containment (the CRITICAL escape)', () => {
|
||||
// Symlink creation can fail without privilege (esp. Windows file symlinks) — the
|
||||
// fix still applies; these tests self-skip when the env cannot create the link.
|
||||
function trySymlink(target: string, link: string, type: 'junction' | 'file' | 'dir'): boolean {
|
||||
try { fs.symlinkSync(target, link, type); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
it('denies reading through a junction/symlink that escapes the root', async () => {
|
||||
const outside = path.join(tmp, 'outside'); fs.mkdirSync(outside, { recursive: true });
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'TOPSECRET');
|
||||
const root = path.join(tmp, 'proj'); fs.mkdirSync(root, { recursive: true });
|
||||
if (!trySymlink(outside, path.join(root, 'escape'), 'junction')) return;
|
||||
const s = new LinkedDirStore(root);
|
||||
await expect(s.readFile('escape/secret.txt')).rejects.toThrow(/traversal denied/i);
|
||||
});
|
||||
|
||||
it('still allows a symlink that stays inside the root (monorepo-style link)', async () => {
|
||||
const root = path.join(tmp, 'proj2'); fs.mkdirSync(path.join(root, 'real'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'real', 'data.txt'), 'OK');
|
||||
if (!trySymlink(path.join(root, 'real'), path.join(root, 'alias'), 'junction')) return;
|
||||
const s = new LinkedDirStore(root);
|
||||
expect((await s.readFile('alias/data.txt')).toString()).toBe('OK');
|
||||
});
|
||||
|
||||
it('denies a symlink that launders an in-root secret past a benign name', async () => {
|
||||
const root = path.join(tmp, 'proj3'); fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, '.env'), 'SECRET=1');
|
||||
if (!trySymlink(path.join(root, '.env'), path.join(root, 'notes'), 'file')) return;
|
||||
const s = new LinkedDirStore(root);
|
||||
await expect(s.readFile('notes')).rejects.toThrow(/sensitive file denied/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalFileStore is sandboxed — no sensitive-file deny', () => {
|
||||
it('allows a .env in the virtual workspace (the agent\'s own scratch)', async () => {
|
||||
const s = new LocalFileStore(tmp, 'ws2');
|
||||
await s.writeFile('.env', 'LOCAL=1');
|
||||
expect((await s.readFile('.env')).toString()).toBe('LOCAL=1');
|
||||
});
|
||||
});
|
||||
266
packages/core/tests/file-store-s3.test.ts
Normal file
266
packages/core/tests/file-store-s3.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { S3FileStore, type S3Config } from '../src/file-store.js';
|
||||
|
||||
// ── Mock @aws-sdk/client-s3 ────────────────────────────────────────
|
||||
|
||||
const mockSend = vi.fn();
|
||||
|
||||
type CommandInput = Record<string, unknown>;
|
||||
|
||||
class MockS3Client {
|
||||
send = mockSend;
|
||||
constructor(_config: unknown) {}
|
||||
}
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
return {
|
||||
S3Client: MockS3Client,
|
||||
GetObjectCommand: class { [k: string]: unknown; _type = 'GetObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
||||
PutObjectCommand: class { [k: string]: unknown; _type = 'PutObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
||||
DeleteObjectCommand: class { [k: string]: unknown; _type = 'DeleteObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
||||
ListObjectsV2Command: class { [k: string]: unknown; _type = 'ListObjects'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
||||
CopyObjectCommand: class { [k: string]: unknown; _type = 'CopyObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
||||
};
|
||||
});
|
||||
|
||||
// ── Test setup ──────────────────────────────────────────────────────
|
||||
|
||||
const testConfig: S3Config = {
|
||||
endpoint: 'minio:9000',
|
||||
bucket: 'waggle-files',
|
||||
accessKey: 'waggle',
|
||||
secretKey: 'waggle_s3_prod',
|
||||
prefix: 'workspaces/ws-123/',
|
||||
};
|
||||
|
||||
describe('S3FileStore', () => {
|
||||
let store: S3FileStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new S3FileStore(testConfig);
|
||||
});
|
||||
|
||||
// ── Meta ────────────────────────────────────────────────────────
|
||||
|
||||
it('getStorageType returns virtual', () => {
|
||||
expect(store.getStorageType()).toBe('virtual');
|
||||
});
|
||||
|
||||
it('getRootPath returns s3:// URL', () => {
|
||||
expect(store.getRootPath()).toBe('s3://waggle-files/workspaces/ws-123/');
|
||||
});
|
||||
|
||||
// ── writeFile ───────────────────────────────────────────────────
|
||||
|
||||
it('writeFile sends PutObjectCommand with correct bucket and key', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
await store.writeFile('docs/notes.md', 'hello world');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd._type).toBe('PutObject');
|
||||
expect(cmd.Bucket).toBe('waggle-files');
|
||||
expect(cmd.Key).toBe('workspaces/ws-123/docs/notes.md');
|
||||
expect(cmd.Body).toEqual(Buffer.from('hello world'));
|
||||
});
|
||||
|
||||
it('writeFile accepts Buffer content', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
await store.writeFile('image.png', buf);
|
||||
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd.Body).toBe(buf);
|
||||
});
|
||||
|
||||
// ── readFile ────────────────────────────────────────────────────
|
||||
|
||||
it('readFile sends GetObjectCommand and returns Buffer', async () => {
|
||||
const chunks = [Buffer.from('chunk1'), Buffer.from('chunk2')];
|
||||
const asyncIterable = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const c of chunks) yield c;
|
||||
},
|
||||
};
|
||||
mockSend.mockResolvedValueOnce({ Body: asyncIterable });
|
||||
|
||||
const result = await store.readFile('docs/notes.md');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd._type).toBe('GetObject');
|
||||
expect(cmd.Bucket).toBe('waggle-files');
|
||||
expect(cmd.Key).toBe('workspaces/ws-123/docs/notes.md');
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.toString()).toBe('chunk1chunk2');
|
||||
});
|
||||
|
||||
// ── deleteFile ──────────────────────────────────────────────────
|
||||
|
||||
it('deleteFile sends DeleteObjectCommand', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
await store.deleteFile('old-file.txt');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd._type).toBe('DeleteObject');
|
||||
expect(cmd.Bucket).toBe('waggle-files');
|
||||
expect(cmd.Key).toBe('workspaces/ws-123/old-file.txt');
|
||||
});
|
||||
|
||||
// ── listFiles ───────────────────────────────────────────────────
|
||||
|
||||
it('listFiles sends ListObjectsV2Command and returns FileEntry[]', async () => {
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'workspaces/ws-123/readme.md', Size: 1024, LastModified: new Date('2025-06-01T00:00:00Z') },
|
||||
{ Key: 'workspaces/ws-123/src/index.ts', Size: 512, LastModified: new Date('2025-06-02T00:00:00Z') },
|
||||
],
|
||||
CommonPrefixes: [
|
||||
{ Prefix: 'workspaces/ws-123/docs/' },
|
||||
],
|
||||
});
|
||||
|
||||
const entries = await store.listFiles();
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd._type).toBe('ListObjects');
|
||||
expect(cmd.Bucket).toBe('waggle-files');
|
||||
expect(cmd.Prefix).toBe('workspaces/ws-123/');
|
||||
expect(cmd.Delimiter).toBe('/');
|
||||
|
||||
expect(entries).toHaveLength(3);
|
||||
// File entries
|
||||
expect(entries[0]).toEqual({
|
||||
name: 'readme.md',
|
||||
path: 'readme.md',
|
||||
size: 1024,
|
||||
modified: '2025-06-01T00:00:00.000Z',
|
||||
isDirectory: false,
|
||||
});
|
||||
expect(entries[1]).toEqual({
|
||||
name: 'index.ts',
|
||||
path: 'src/index.ts',
|
||||
size: 512,
|
||||
modified: '2025-06-02T00:00:00.000Z',
|
||||
isDirectory: false,
|
||||
});
|
||||
// Directory entry
|
||||
expect(entries[2]).toEqual({
|
||||
name: 'docs',
|
||||
path: 'docs',
|
||||
size: 0,
|
||||
modified: '',
|
||||
isDirectory: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('listFiles with directory argument appends to prefix', async () => {
|
||||
mockSend.mockResolvedValueOnce({ Contents: [], CommonPrefixes: [] });
|
||||
|
||||
await store.listFiles('src');
|
||||
|
||||
const cmd = mockSend.mock.calls[0][0];
|
||||
expect(cmd.Prefix).toBe('workspaces/ws-123/src/');
|
||||
});
|
||||
|
||||
it('listFiles handles empty response', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
|
||||
const entries = await store.listFiles();
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
// ── moveFile ────────────────────────────────────────────────────
|
||||
|
||||
it('moveFile copies then deletes', async () => {
|
||||
mockSend.mockResolvedValueOnce({}); // CopyObject
|
||||
mockSend.mockResolvedValueOnce({}); // DeleteObject
|
||||
|
||||
await store.moveFile('old.txt', 'new.txt');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
||||
const copyCmd = mockSend.mock.calls[0][0];
|
||||
expect(copyCmd._type).toBe('CopyObject');
|
||||
expect(copyCmd.CopySource).toBe('waggle-files/workspaces/ws-123/old.txt');
|
||||
expect(copyCmd.Key).toBe('workspaces/ws-123/new.txt');
|
||||
|
||||
const deleteCmd = mockSend.mock.calls[1][0];
|
||||
expect(deleteCmd._type).toBe('DeleteObject');
|
||||
expect(deleteCmd.Key).toBe('workspaces/ws-123/old.txt');
|
||||
});
|
||||
|
||||
// ── searchFiles ─────────────────────────────────────────────────
|
||||
|
||||
it('searchFiles filters listFiles results by pattern', async () => {
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'workspaces/ws-123/readme.md', Size: 100, LastModified: new Date() },
|
||||
{ Key: 'workspaces/ws-123/notes.txt', Size: 200, LastModified: new Date() },
|
||||
{ Key: 'workspaces/ws-123/data.md', Size: 50, LastModified: new Date() },
|
||||
],
|
||||
CommonPrefixes: [],
|
||||
});
|
||||
|
||||
const results = await store.searchFiles('*.md');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].name).toBe('readme.md');
|
||||
expect(results[1].name).toBe('data.md');
|
||||
});
|
||||
|
||||
it('searchFiles is ReDoS-safe with a hostile regex pattern', async () => {
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Contents: [{ Key: 'workspaces/ws-123/' + 'a'.repeat(40) + 'X', Size: 1, LastModified: new Date() }],
|
||||
CommonPrefixes: [],
|
||||
});
|
||||
const start = Date.now();
|
||||
// Under the old `new RegExp(pattern…)` this built /(a+)+$/i and catastrophically
|
||||
// backtracked on the key. Now the metachars are escaped → a literal match → instant.
|
||||
const results = await store.searchFiles('(a+)+$');
|
||||
expect(Date.now() - start).toBeLessThan(1000);
|
||||
expect(results).toHaveLength(0); // matched literally; no key contains "(a+)+$"
|
||||
});
|
||||
|
||||
// ── key traversal protection ────────────────────────────────────
|
||||
|
||||
it('rejects ../ traversal in every op BEFORE any S3 call', async () => {
|
||||
await expect(store.readFile('../ws-456/secret')).rejects.toThrow(/traversal denied/i);
|
||||
await expect(store.writeFile('../ws-456/x', 'y')).rejects.toThrow(/traversal denied/i);
|
||||
await expect(store.deleteFile('../../etc/passwd')).rejects.toThrow(/traversal denied/i);
|
||||
await expect(store.moveFile('ok', '../ws-456/b')).rejects.toThrow(/traversal denied/i);
|
||||
await expect(store.listFiles('../ws-456')).rejects.toThrow(/traversal denied/i);
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a normal nested key', async () => {
|
||||
mockSend.mockResolvedValueOnce({});
|
||||
await store.writeFile('docs/sub/file.md', 'x');
|
||||
expect(mockSend.mock.calls[0][0].Key).toBe('workspaces/ws-123/docs/sub/file.md');
|
||||
});
|
||||
|
||||
// ── getStorageInfo ──────────────────────────────────────────────
|
||||
|
||||
it('getStorageInfo sums sizes from S3 listing', async () => {
|
||||
mockSend.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'workspaces/ws-123/a.txt', Size: 100 },
|
||||
{ Key: 'workspaces/ws-123/b.txt', Size: 250 },
|
||||
{ Key: 'workspaces/ws-123/c.txt', Size: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const info = await store.getStorageInfo();
|
||||
|
||||
expect(info).toEqual({
|
||||
usedBytes: 400,
|
||||
fileCount: 3,
|
||||
storageType: 'virtual',
|
||||
});
|
||||
});
|
||||
});
|
||||
44
packages/core/tests/install-audit-check-parity.test.ts
Normal file
44
packages/core/tests/install-audit-check-parity.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* P7/D15 A3 — drift-lock for the install_audit CHECK constraints.
|
||||
*
|
||||
* The table is declared in TWO places: install-audit.ts (core, generated from
|
||||
* the canonical @waggle/shared arrays) and hive-mind-core/src/mind/schema.ts
|
||||
* (the OSS substrate, a standalone literal). They MUST produce identical CHECK
|
||||
* lists or auditStore.record() crashes on one path. This test pins both to the
|
||||
* single canonical source, so a drift in either fails CI instead of production.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
sqlInList, RISK_LEVELS, APPROVAL_CLASSES, AUDIT_ACTIONS,
|
||||
AUDIT_CAPABILITY_TYPES, AUDIT_INITIATORS, TRUST_SOURCES,
|
||||
} from '@waggle/shared';
|
||||
import { SCHEMA_SQL } from '@waggle/hive-mind-core';
|
||||
import { INSTALL_AUDIT_TABLE_SQL } from '../src/install-audit.js';
|
||||
|
||||
const COLUMNS = [
|
||||
{ col: 'capability_type', values: AUDIT_CAPABILITY_TYPES },
|
||||
{ col: 'risk_level', values: RISK_LEVELS },
|
||||
{ col: 'trust_source', values: TRUST_SOURCES },
|
||||
{ col: 'approval_class', values: APPROVAL_CLASSES },
|
||||
{ col: 'action', values: AUDIT_ACTIONS },
|
||||
{ col: 'initiator', values: AUDIT_INITIATORS },
|
||||
] as const;
|
||||
|
||||
describe('install_audit CHECK parity (A3)', () => {
|
||||
for (const { col, values } of COLUMNS) {
|
||||
const expected = `${col} IN (${sqlInList(values)})`;
|
||||
|
||||
it(`core install-audit DDL pins ${col} to the canonical list`, () => {
|
||||
expect(INSTALL_AUDIT_TABLE_SQL).toContain(expected);
|
||||
});
|
||||
|
||||
it(`OSS substrate schema.ts pins ${col} to the canonical list`, () => {
|
||||
expect(SCHEMA_SQL).toContain(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('both DDLs accept the P5/D4 uninstalled action', () => {
|
||||
expect(INSTALL_AUDIT_TABLE_SQL).toContain("'uninstalled'");
|
||||
expect(SCHEMA_SQL).toContain("'uninstalled'");
|
||||
});
|
||||
});
|
||||
479
packages/core/tests/install-audit.test.ts
Normal file
479
packages/core/tests/install-audit.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* InstallAuditStore tests.
|
||||
*
|
||||
* Relocated here from packages/hive-mind-core/tests/mind/ — commit 05c9ec3
|
||||
* ("relocate substrate tests") moved this test to hive-mind-core, but the
|
||||
* source (`install-audit.ts`) stayed in @waggle/core and imports MindDB FROM
|
||||
* @waggle/hive-mind-core. hive-mind-core cannot depend back on @waggle/core
|
||||
* (dependency inversion + breaks the OSS parity model), so the relocated test
|
||||
* imported a non-existent `../../src/install-audit.js` and the entire suite
|
||||
* silently failed at collection — masking the FIX-3 CHECK-drift regression.
|
||||
* Its correct home is alongside the source, in @waggle/core.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { InstallAuditStore, type RecordAuditInput } from '../src/install-audit.js';
|
||||
|
||||
describe('InstallAuditStore', () => {
|
||||
let tmpDir: string;
|
||||
let db: MindDB;
|
||||
let store: InstallAuditStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-audit-'));
|
||||
db = new MindDB(path.join(tmpDir, 'test.mind'));
|
||||
store = new InstallAuditStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeInput(overrides?: Partial<RecordAuditInput>): RecordAuditInput {
|
||||
return {
|
||||
capabilityName: 'risk-assessment',
|
||||
capabilityType: 'skill',
|
||||
source: 'starter-pack',
|
||||
riskLevel: 'low',
|
||||
trustSource: 'starter_pack',
|
||||
approvalClass: 'standard',
|
||||
action: 'installed',
|
||||
initiator: 'agent',
|
||||
detail: 'Installed successfully',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("records an 'uninstalled' action (P5/D4 — capability removal trail)", () => {
|
||||
const entry = store.record(makeInput({ action: 'uninstalled', initiator: 'agent', detail: 'deleted by agent' }));
|
||||
expect(entry.action).toBe('uninstalled');
|
||||
expect(store.getByAction('uninstalled')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("migrates a legacy-CHECK table to accept 'uninstalled' (P5/D4)", () => {
|
||||
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-audit-legacy-'));
|
||||
const legacyDb = new MindDB(path.join(tmp2, 'legacy.mind'));
|
||||
const raw = legacyDb.getDatabase();
|
||||
// Simulate a pre-P5 install: drop the migrated table and recreate it with
|
||||
// the OLD narrower action CHECK (no 'uninstalled'), seeding one row.
|
||||
raw.exec('DROP TABLE IF EXISTS install_audit');
|
||||
raw.exec(`
|
||||
CREATE TABLE install_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
capability_name TEXT NOT NULL,
|
||||
capability_type TEXT NOT NULL CHECK (capability_type IN ('native','skill','plugin','mcp','connector','marketplace')),
|
||||
source TEXT NOT NULL,
|
||||
version TEXT,
|
||||
risk_level TEXT NOT NULL CHECK (risk_level IN ('low','medium','high','critical')),
|
||||
trust_source TEXT NOT NULL,
|
||||
approval_class TEXT NOT NULL CHECK (approval_class IN ('standard','elevated','critical','blocked')),
|
||||
action TEXT NOT NULL CHECK (action IN ('proposed','approved','installed','rejected','failed','blocked')),
|
||||
initiator TEXT NOT NULL CHECK (initiator IN ('agent','user','system')),
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
raw.prepare(`INSERT INTO install_audit
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('legacy-skill','skill','starter-pack','low','starter_pack','standard','installed','user','pre-migration row')`).run();
|
||||
|
||||
// Constructing the store triggers ensureTable() → rebuild migration.
|
||||
const migrated = new InstallAuditStore(legacyDb);
|
||||
// Pre-existing row survives the rebuild.
|
||||
expect(migrated.getByCapability('legacy-skill')).toHaveLength(1);
|
||||
// The widened CHECK now accepts 'uninstalled' (would throw on a stale table).
|
||||
const entry = migrated.record(makeInput({ capabilityName: 'legacy-skill', action: 'uninstalled', detail: 'removed' }));
|
||||
expect(entry.action).toBe('uninstalled');
|
||||
// Review #1: the rebuild must NOT drop the declared indexes (SQLite RENAME
|
||||
// carries index names to the legacy table; without an explicit DROP INDEX
|
||||
// they get destroyed with it, leaving the audit table index-less).
|
||||
const idx = raw.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='install_audit' AND name LIKE 'idx_audit_%'",
|
||||
).all() as Array<{ name: string }>;
|
||||
expect(idx.map(i => i.name).sort()).toEqual(['idx_audit_capability', 'idx_audit_timestamp']);
|
||||
|
||||
// #15: the rebuild also added the trust_source CHECK (the legacy table had
|
||||
// trust_source unconstrained). The migrated DDL now carries it, and the
|
||||
// pre-migration row (trust_source 'starter_pack') survived because every
|
||||
// historical value is in the canonical 7-set.
|
||||
const ddl = raw.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='install_audit'",
|
||||
).get() as { sql: string };
|
||||
expect(ddl.sql).toContain('CHECK (trust_source IN');
|
||||
// A bogus trust_source is now rejected at the DB (was previously accepted).
|
||||
expect(() => raw.prepare(
|
||||
`INSERT INTO install_audit
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('x','skill','s','low','BOGUS_SOURCE','standard','installed','user','')`,
|
||||
).run()).toThrow();
|
||||
|
||||
legacyDb.close();
|
||||
fs.rmSync(tmp2, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('records and retrieves an audit entry', () => {
|
||||
const entry = store.record(makeInput());
|
||||
expect(entry.id).toBeGreaterThan(0);
|
||||
expect(entry.capability_name).toBe('risk-assessment');
|
||||
expect(entry.action).toBe('installed');
|
||||
expect(entry.risk_level).toBe('low');
|
||||
expect(entry.trust_source).toBe('starter_pack');
|
||||
expect(entry.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it('records multiple events for same capability', () => {
|
||||
store.record(makeInput({ action: 'proposed' }));
|
||||
store.record(makeInput({ action: 'approved' }));
|
||||
store.record(makeInput({ action: 'installed' }));
|
||||
|
||||
const history = store.getByCapability('risk-assessment');
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0].action).toBe('installed');
|
||||
expect(history[2].action).toBe('proposed');
|
||||
});
|
||||
|
||||
it('queries by action type', () => {
|
||||
store.record(makeInput({ capabilityName: 'draft-memo', action: 'installed' }));
|
||||
store.record(makeInput({ capabilityName: 'code-review', action: 'proposed' }));
|
||||
store.record(makeInput({ capabilityName: 'brainstorm', action: 'installed' }));
|
||||
|
||||
const installed = store.getByAction('installed');
|
||||
expect(installed).toHaveLength(2);
|
||||
expect(installed.map(e => e.capability_name).sort()).toEqual(['brainstorm', 'draft-memo']);
|
||||
});
|
||||
|
||||
it('retrieves recent entries in descending order', () => {
|
||||
store.record(makeInput({ capabilityName: 'first', action: 'proposed' }));
|
||||
store.record(makeInput({ capabilityName: 'second', action: 'installed' }));
|
||||
store.record(makeInput({ capabilityName: 'third', action: 'failed' }));
|
||||
|
||||
const recent = store.getRecent(2);
|
||||
expect(recent).toHaveLength(2);
|
||||
expect(recent[0].capability_name).toBe('third');
|
||||
expect(recent[1].capability_name).toBe('second');
|
||||
});
|
||||
|
||||
it('preserves all fields round-trip', () => {
|
||||
const entry = store.record(makeInput({
|
||||
capabilityName: 'test-skill',
|
||||
capabilityType: 'plugin',
|
||||
source: 'third-party',
|
||||
version: '1.2.3',
|
||||
riskLevel: 'high',
|
||||
trustSource: 'third_party_unverified',
|
||||
approvalClass: 'critical',
|
||||
action: 'failed',
|
||||
initiator: 'user',
|
||||
detail: 'Permission denied by user',
|
||||
}));
|
||||
|
||||
expect(entry.capability_name).toBe('test-skill');
|
||||
expect(entry.capability_type).toBe('plugin');
|
||||
expect(entry.source).toBe('third-party');
|
||||
expect(entry.version).toBe('1.2.3');
|
||||
expect(entry.risk_level).toBe('high');
|
||||
expect(entry.trust_source).toBe('third_party_unverified');
|
||||
expect(entry.approval_class).toBe('critical');
|
||||
expect(entry.action).toBe('failed');
|
||||
expect(entry.initiator).toBe('user');
|
||||
expect(entry.detail).toBe('Permission denied by user');
|
||||
});
|
||||
|
||||
it('handles null version', () => {
|
||||
const entry = store.record(makeInput({ version: null }));
|
||||
expect(entry.version).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty detail', () => {
|
||||
const entry = store.record(makeInput({ detail: undefined }));
|
||||
expect(entry.detail).toBe('');
|
||||
});
|
||||
|
||||
it('getAll returns entries in insertion order', () => {
|
||||
store.record(makeInput({ capabilityName: 'a' }));
|
||||
store.record(makeInput({ capabilityName: 'b' }));
|
||||
store.record(makeInput({ capabilityName: 'c' }));
|
||||
|
||||
const all = store.getAll();
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all[0].capability_name).toBe('a');
|
||||
expect(all[2].capability_name).toBe('c');
|
||||
});
|
||||
|
||||
it('clear removes all entries', () => {
|
||||
store.record(makeInput({ capabilityName: 'a' }));
|
||||
store.record(makeInput({ capabilityName: 'b' }));
|
||||
expect(store.getAll()).toHaveLength(2);
|
||||
|
||||
store.clear();
|
||||
expect(store.getAll()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates table lazily on pre-existing databases', () => {
|
||||
const store2 = new InstallAuditStore(db);
|
||||
const entry = store2.record(makeInput({ capabilityName: 'from-second-store' }));
|
||||
expect(entry.capability_name).toBe('from-second-store');
|
||||
});
|
||||
|
||||
it('records failed validation as audit event', () => {
|
||||
const entry = store.record(makeInput({
|
||||
action: 'failed',
|
||||
riskLevel: 'low',
|
||||
trustSource: 'unknown',
|
||||
detail: 'Skill "nonexistent" not found in the starter pack.',
|
||||
}));
|
||||
expect(entry.action).toBe('failed');
|
||||
expect(entry.detail).toContain('not found');
|
||||
});
|
||||
|
||||
// P0-005/FIX-3: install_audit CHECK constraints drifted behind their TS
|
||||
// type unions. Once marketplace FTS search returned candidates,
|
||||
// acquire_capability recommended a `type:'marketplace'` capability and
|
||||
// auditStore.record() crashed with "CHECK constraint failed: capability_type
|
||||
// IN ('native','skill','plugin','mcp')", throwing the whole tool and
|
||||
// dead-ending the agent. These lock the CHECK <-> type-union alignment.
|
||||
it('records a marketplace-type capability (capability_type CHECK widened)', () => {
|
||||
const entry = store.record(makeInput({ capabilityType: 'marketplace', source: 'marketplace' }));
|
||||
expect(entry.capability_type).toBe('marketplace');
|
||||
});
|
||||
|
||||
it('records a connector-type capability', () => {
|
||||
const entry = store.record(makeInput({ capabilityType: 'connector', source: 'connector' }));
|
||||
expect(entry.capability_type).toBe('connector');
|
||||
});
|
||||
|
||||
it("records the 'blocked' action and 'blocked' approval_class (sibling CHECK drift)", () => {
|
||||
const entry = store.record(makeInput({ action: 'blocked', approvalClass: 'blocked' }));
|
||||
expect(entry.action).toBe('blocked');
|
||||
expect(entry.approval_class).toBe('blocked');
|
||||
});
|
||||
|
||||
// M2 (UX-Refactor Phase 4 / C15): risk_level CHECK lacked 'critical' while
|
||||
// the TS union had it — marketplace.ts's CRITICAL-block audit write was
|
||||
// silently rejected (throw swallowed by `catch {}`). This locks the widened
|
||||
// CHECK on fresh databases.
|
||||
it("records riskLevel 'critical' (M2: risk_level CHECK widened)", () => {
|
||||
const entry = store.record(makeInput({
|
||||
riskLevel: 'critical',
|
||||
approvalClass: 'blocked',
|
||||
action: 'blocked',
|
||||
trustSource: 'security-gate',
|
||||
detail: 'SecurityGate blocked: CRITICAL findings',
|
||||
}));
|
||||
expect(entry.risk_level).toBe('critical');
|
||||
expect(entry.action).toBe('blocked');
|
||||
});
|
||||
|
||||
// C18: type-filtered read backing GET /api/extend/audit?type=
|
||||
it('getRecentByType filters by capability_type, most recent first', () => {
|
||||
store.record(makeInput({ capabilityName: 'a-skill', capabilityType: 'skill' }));
|
||||
store.record(makeInput({ capabilityName: 'a-server', capabilityType: 'mcp' }));
|
||||
store.record(makeInput({ capabilityName: 'b-server', capabilityType: 'mcp' }));
|
||||
store.record(makeInput({ capabilityName: 'a-conn', capabilityType: 'connector' }));
|
||||
|
||||
const mcps = store.getRecentByType('mcp');
|
||||
expect(mcps).toHaveLength(2);
|
||||
expect(mcps[0].capability_name).toBe('b-server');
|
||||
expect(mcps[1].capability_name).toBe('a-server');
|
||||
expect(store.getRecentByType('mcp', 1)).toHaveLength(1);
|
||||
expect(store.getRecentByType('native')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstallAuditStore — legacy CHECK migration', () => {
|
||||
let tmpDir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-audit-mig-'));
|
||||
dbPath = path.join(tmpDir, 'legacy.mind');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const OLD_DDL = `CREATE TABLE install_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
capability_name TEXT NOT NULL,
|
||||
capability_type TEXT NOT NULL CHECK (capability_type IN ('native', 'skill', 'plugin', 'mcp')),
|
||||
source TEXT NOT NULL,
|
||||
version TEXT,
|
||||
risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')),
|
||||
trust_source TEXT NOT NULL,
|
||||
approval_class TEXT NOT NULL CHECK (approval_class IN ('standard', 'elevated', 'critical')),
|
||||
action TEXT NOT NULL CHECK (action IN ('proposed', 'approved', 'installed', 'rejected', 'failed')),
|
||||
initiator TEXT NOT NULL CHECK (initiator IN ('agent', 'user', 'system')),
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
)`;
|
||||
|
||||
it('rebuilds a legacy install_audit table (narrow CHECK) and preserves rows', () => {
|
||||
// 1. Create the mind DB, then forcibly downgrade install_audit to the
|
||||
// historical 4-value CHECK with one legacy row — simulating a real
|
||||
// user .mind created before connector/marketplace existed.
|
||||
{
|
||||
const seed = new MindDB(dbPath);
|
||||
const raw = seed.getDatabase();
|
||||
raw.prepare('DROP TABLE IF EXISTS install_audit').run();
|
||||
raw.prepare(OLD_DDL).run();
|
||||
raw.prepare(`INSERT INTO install_audit
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('legacy-skill','skill','starter-pack','low','starter_pack','standard','installed','agent','pre-migration row')`).run();
|
||||
seed.close();
|
||||
}
|
||||
|
||||
// 2. Reopen — runMigrations() must rebuild install_audit with the widened CHECK.
|
||||
const db = new MindDB(dbPath);
|
||||
const store = new InstallAuditStore(db);
|
||||
|
||||
const legacy = store.getByCapability('legacy-skill');
|
||||
expect(legacy).toHaveLength(1);
|
||||
expect(legacy[0].detail).toBe('pre-migration row');
|
||||
|
||||
expect(() => store.record({
|
||||
capabilityName: 'filesystem', capabilityType: 'marketplace', source: 'marketplace',
|
||||
riskLevel: 'medium', trustSource: 'unknown', approvalClass: 'standard',
|
||||
action: 'proposed', initiator: 'agent', detail: 'Proposed for need: read external files',
|
||||
})).not.toThrow();
|
||||
const fs2 = store.getByCapability('filesystem');
|
||||
expect(fs2[0].capability_type).toBe('marketplace');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// M2 (UX-Refactor Phase 4 / C15): the FIX-3-era DDL had every list widened
|
||||
// EXCEPT risk_level — the exact shape real .minds created between FIX-3
|
||||
// (2026-05-17) and Phase 4 are in. Only the new "'low', 'medium', 'high',
|
||||
// 'critical'" sentinel triggers this rebuild ('critical' alone appears in
|
||||
// approval_class, so it is NOT the key).
|
||||
const FIX3_ERA_DDL = `CREATE TABLE install_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
capability_name TEXT NOT NULL,
|
||||
capability_type TEXT NOT NULL CHECK (capability_type IN ('native', 'skill', 'plugin', 'mcp', 'connector', 'marketplace')),
|
||||
source TEXT NOT NULL,
|
||||
version TEXT,
|
||||
risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high')),
|
||||
trust_source TEXT NOT NULL,
|
||||
approval_class TEXT NOT NULL CHECK (approval_class IN ('standard', 'elevated', 'critical', 'blocked')),
|
||||
action TEXT NOT NULL CHECK (action IN ('proposed', 'approved', 'installed', 'rejected', 'failed', 'blocked')),
|
||||
initiator TEXT NOT NULL CHECK (initiator IN ('agent', 'user', 'system')),
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
)`;
|
||||
|
||||
it("M2: rebuilds a FIX-3-era table (risk_level missing 'critical') and preserves rows", () => {
|
||||
{
|
||||
const seed = new MindDB(dbPath);
|
||||
const raw = seed.getDatabase();
|
||||
raw.prepare('DROP TABLE IF EXISTS install_audit').run();
|
||||
raw.prepare(FIX3_ERA_DDL).run();
|
||||
raw.prepare(`INSERT INTO install_audit
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('legacy-mcp','mcp','marketplace','high','security-gate','blocked','blocked','system','pre-M2 row')`).run();
|
||||
// Sanity: the legacy CHECK really rejects 'critical' (the live bug)
|
||||
expect(() => raw.prepare(`INSERT INTO install_audit
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('x','mcp','marketplace','critical','security-gate','blocked','blocked','system','')`).run()
|
||||
).toThrow(/CHECK/);
|
||||
seed.close();
|
||||
}
|
||||
|
||||
// Reopen — runMigrations() must rebuild keyed on the M2 sentinel.
|
||||
const db = new MindDB(dbPath);
|
||||
const store = new InstallAuditStore(db);
|
||||
|
||||
const legacy = store.getByCapability('legacy-mcp');
|
||||
expect(legacy).toHaveLength(1);
|
||||
expect(legacy[0].detail).toBe('pre-M2 row');
|
||||
expect(legacy[0].risk_level).toBe('high');
|
||||
|
||||
const critical = store.record({
|
||||
capabilityName: 'evil-pkg', capabilityType: 'marketplace', source: 'marketplace',
|
||||
riskLevel: 'critical', trustSource: 'security-gate', approvalClass: 'blocked',
|
||||
action: 'blocked', initiator: 'system', detail: 'SecurityGate blocked: CRITICAL',
|
||||
});
|
||||
expect(critical.risk_level).toBe('critical');
|
||||
db.close();
|
||||
|
||||
// Idempotence: a second reopen must NOT rebuild again (rows + ids stable).
|
||||
const db2 = new MindDB(dbPath);
|
||||
const store2 = new InstallAuditStore(db2);
|
||||
const all = store2.getAll();
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all.map((e) => e.capability_name)).toEqual(['legacy-mcp', 'evil-pkg']);
|
||||
// The rebuilt DDL carries the sentinel, so a third store write still works.
|
||||
expect(() => store2.record({
|
||||
capabilityName: 'again', capabilityType: 'mcp', source: 'mcp',
|
||||
riskLevel: 'critical', trustSource: 'security-gate', approvalClass: 'blocked',
|
||||
action: 'blocked', initiator: 'system',
|
||||
})).not.toThrow();
|
||||
db2.close();
|
||||
});
|
||||
|
||||
// The rebuild now runs in ONE transaction, so a crash mid-rebuild rolls
|
||||
// back — but DBs damaged by a PRE-transactional crashed rebuild exist in the
|
||||
// wild with all rows stranded in install_audit__mig_old. These lock the
|
||||
// recovery the FIX-3 comment always promised but never performed.
|
||||
const LEGACY_ROW_INSERT = (table: string) => `INSERT INTO ${table}
|
||||
(capability_name, capability_type, source, risk_level, trust_source, approval_class, action, initiator, detail)
|
||||
VALUES ('stranded','mcp','marketplace','high','security-gate','blocked','blocked','system','pre-crash row')`;
|
||||
|
||||
it('recovers rows stranded by a crash between RENAME and recreate (install_audit missing)', () => {
|
||||
{
|
||||
const seed = new MindDB(dbPath);
|
||||
const raw = seed.getDatabase();
|
||||
raw.prepare('DROP TABLE IF EXISTS install_audit').run();
|
||||
raw.prepare(FIX3_ERA_DDL).run();
|
||||
raw.prepare(LEGACY_ROW_INSERT('install_audit')).run();
|
||||
// Simulate the pre-transactional crash: renamed aside, then process died
|
||||
// before SCHEMA_SQL recreated install_audit.
|
||||
raw.prepare('ALTER TABLE install_audit RENAME TO install_audit__mig_old').run();
|
||||
seed.close();
|
||||
}
|
||||
|
||||
const db = new MindDB(dbPath);
|
||||
const store = new InstallAuditStore(db);
|
||||
// Rows restored AND the rebuild completed (the restored table had the
|
||||
// FIX-3-era DDL, so the M2 sentinel re-triggered the rebuild)
|
||||
const rows = store.getByCapability('stranded');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].detail).toBe('pre-crash row');
|
||||
expect(() => store.record({
|
||||
capabilityName: 'post-recovery', capabilityType: 'mcp', source: 'mcp',
|
||||
riskLevel: 'critical', trustSource: 'security-gate', approvalClass: 'blocked',
|
||||
action: 'blocked', initiator: 'system',
|
||||
})).not.toThrow();
|
||||
// No stale __mig_old left to be destroyed by a future rebuild
|
||||
const leftover = db.getDatabase().prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='install_audit__mig_old'",
|
||||
).get();
|
||||
expect(leftover).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('recovers rows stranded by a crash between recreate and copy-back (fresh empty install_audit)', () => {
|
||||
{
|
||||
const seed = new MindDB(dbPath); // creates the CURRENT empty install_audit
|
||||
const raw = seed.getDatabase();
|
||||
raw.prepare(FIX3_ERA_DDL.replace('CREATE TABLE install_audit', 'CREATE TABLE install_audit__mig_old')).run();
|
||||
raw.prepare(LEGACY_ROW_INSERT('install_audit__mig_old')).run();
|
||||
seed.close();
|
||||
}
|
||||
|
||||
const db = new MindDB(dbPath);
|
||||
const store = new InstallAuditStore(db);
|
||||
const rows = store.getByCapability('stranded');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].detail).toBe('pre-crash row');
|
||||
const leftover = db.getDatabase().prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='install_audit__mig_old'",
|
||||
).get();
|
||||
expect(leftover).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
162
packages/core/tests/litellm-embedder.test.ts
Normal file
162
packages/core/tests/litellm-embedder.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createLiteLLMEmbedder } from '@waggle/hive-mind-core';
|
||||
|
||||
describe('createLiteLLMEmbedder', () => {
|
||||
const baseConfig = {
|
||||
litellmUrl: 'http://localhost:4000/v1',
|
||||
litellmApiKey: 'sk-test',
|
||||
model: 'text-embedding',
|
||||
dimensions: 8,
|
||||
};
|
||||
|
||||
function mockFetchOk(data: unknown) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => data,
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
it('exposes the configured dimensions', () => {
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, dimensions: 256, fetch: mockFetchOk({}) });
|
||||
expect(embedder.dimensions).toBe(256);
|
||||
});
|
||||
|
||||
it('defaults dimensions to 1024', () => {
|
||||
const embedder = createLiteLLMEmbedder({
|
||||
litellmUrl: 'http://localhost:4000/v1',
|
||||
fetch: mockFetchOk({}),
|
||||
});
|
||||
expect(embedder.dimensions).toBe(1024);
|
||||
});
|
||||
|
||||
it('embed() calls the correct endpoint with Bearer auth', async () => {
|
||||
const fakeFetch = mockFetchOk({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8] }],
|
||||
});
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch });
|
||||
const result = await embedder.embed('hello world');
|
||||
|
||||
expect(fakeFetch).toHaveBeenCalledOnce();
|
||||
const [url, options] = fakeFetch.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:4000/v1/embeddings');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Authorization']).toBe('Bearer sk-test');
|
||||
expect(options.headers['Content-Type']).toBe('application/json');
|
||||
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.model).toBe('text-embedding');
|
||||
expect(body.input).toBe('hello world');
|
||||
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
expect(result.length).toBe(8);
|
||||
expect(result[0]).toBeCloseTo(0.1);
|
||||
});
|
||||
|
||||
it('embed() strips trailing /v1 to avoid double path', async () => {
|
||||
const fakeFetch = mockFetchOk({
|
||||
data: [{ embedding: [1, 2, 3, 4, 5, 6, 7, 8] }],
|
||||
});
|
||||
|
||||
const embedder = createLiteLLMEmbedder({
|
||||
...baseConfig,
|
||||
litellmUrl: 'http://localhost:4000/v1',
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
await embedder.embed('test');
|
||||
|
||||
const [url] = fakeFetch.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:4000/v1/embeddings');
|
||||
});
|
||||
|
||||
it('embedBatch() returns multiple Float32Arrays', async () => {
|
||||
const fakeFetch = mockFetchOk({
|
||||
data: [
|
||||
{ embedding: [1, 2, 3, 4, 5, 6, 7, 8] },
|
||||
{ embedding: [8, 7, 6, 5, 4, 3, 2, 1] },
|
||||
],
|
||||
});
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch });
|
||||
const results = await embedder.embedBatch(['hello', 'world']);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toBeInstanceOf(Float32Array);
|
||||
expect(results[1]).toBeInstanceOf(Float32Array);
|
||||
expect(results[0][0]).toBe(1);
|
||||
expect(results[1][0]).toBe(8);
|
||||
|
||||
// Should send array as input
|
||||
const body = JSON.parse(fakeFetch.mock.calls[0][1].body);
|
||||
expect(body.input).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('embedBatch() returns empty array for empty input', async () => {
|
||||
const fakeFetch = vi.fn();
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch });
|
||||
const results = await embedder.embedBatch([]);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
expect(fakeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on API error when fallbackToMock is false', async () => {
|
||||
const fakeFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
} as unknown as Response);
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch, fallbackToMock: false });
|
||||
await expect(embedder.embed('test')).rejects.toThrow('LiteLLM embeddings error (500)');
|
||||
});
|
||||
|
||||
it('falls back to mock on API error when fallbackToMock is true', async () => {
|
||||
const fakeFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'error',
|
||||
} as unknown as Response);
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch, fallbackToMock: true });
|
||||
const result = await embedder.embed('hello');
|
||||
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
expect(result.length).toBe(8);
|
||||
// Verify it's the deterministic mock: 'h' = 104, (104 - 128) / 128 = -0.1875
|
||||
expect(result[0]).toBeCloseTo(-0.1875);
|
||||
});
|
||||
|
||||
it('falls back to mock on network error when fallbackToMock is true', async () => {
|
||||
const fakeFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch, fallbackToMock: true });
|
||||
const result = await embedder.embed('hi');
|
||||
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
expect(result.length).toBe(8);
|
||||
});
|
||||
|
||||
it('throws on network error when fallbackToMock is false', async () => {
|
||||
const fakeFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const embedder = createLiteLLMEmbedder({ ...baseConfig, fetch: fakeFetch });
|
||||
await expect(embedder.embed('test')).rejects.toThrow('ECONNREFUSED');
|
||||
});
|
||||
|
||||
it('omits Authorization header when no API key is provided', async () => {
|
||||
const fakeFetch = mockFetchOk({
|
||||
data: [{ embedding: [1, 2, 3, 4, 5, 6, 7, 8] }],
|
||||
});
|
||||
|
||||
const embedder = createLiteLLMEmbedder({
|
||||
litellmUrl: 'http://localhost:4000/v1',
|
||||
dimensions: 8,
|
||||
fetch: fakeFetch,
|
||||
});
|
||||
await embedder.embed('test');
|
||||
|
||||
const [, options] = fakeFetch.mock.calls[0];
|
||||
expect(options.headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
340
packages/core/tests/memory-import.test.ts
Normal file
340
packages/core/tests/memory-import.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChatGPTExport, parseClaudeExport, extractKnowledge, processImport } from '../src/memory-import';
|
||||
|
||||
describe('ChatGPT Export Parser', () => {
|
||||
it('parses conversations with mapping structure', () => {
|
||||
const data = [
|
||||
{
|
||||
title: 'Test Chat',
|
||||
create_time: 1709000000,
|
||||
mapping: {
|
||||
'node1': {
|
||||
message: {
|
||||
author: { role: 'user' },
|
||||
content: { parts: ['Hello, I need help with React'] },
|
||||
create_time: 1709000001,
|
||||
},
|
||||
},
|
||||
'node2': {
|
||||
message: {
|
||||
author: { role: 'assistant' },
|
||||
content: { parts: ['Sure, I can help with React!'] },
|
||||
create_time: 1709000002,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = parseChatGPTExport(data);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Test Chat');
|
||||
expect(result[0].messages).toHaveLength(2);
|
||||
expect(result[0].messages[0].role).toBe('user');
|
||||
expect(result[0].messages[0].text).toBe('Hello, I need help with React');
|
||||
expect(result[0].source).toBe('chatgpt');
|
||||
});
|
||||
|
||||
it('skips system messages', () => {
|
||||
const data = [
|
||||
{
|
||||
title: 'Test',
|
||||
mapping: {
|
||||
'sys': { message: { author: { role: 'system' }, content: { parts: ['System prompt'] } } },
|
||||
'user': { message: { author: { role: 'user' }, content: { parts: ['Hi'] }, create_time: 1 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = parseChatGPTExport(data);
|
||||
expect(result[0].messages).toHaveLength(1);
|
||||
expect(result[0].messages[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('handles empty/missing conversations gracefully', () => {
|
||||
expect(parseChatGPTExport(null)).toEqual([]);
|
||||
expect(parseChatGPTExport({})).toEqual([]);
|
||||
expect(parseChatGPTExport([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts messages chronologically by create_time', () => {
|
||||
const data = [
|
||||
{
|
||||
title: 'Order Test',
|
||||
mapping: {
|
||||
'late': {
|
||||
message: {
|
||||
author: { role: 'user' },
|
||||
content: { parts: ['Second message'] },
|
||||
create_time: 200,
|
||||
},
|
||||
},
|
||||
'early': {
|
||||
message: {
|
||||
author: { role: 'user' },
|
||||
content: { parts: ['First message'] },
|
||||
create_time: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = parseChatGPTExport(data);
|
||||
expect(result[0].messages[0].text).toBe('First message');
|
||||
expect(result[0].messages[1].text).toBe('Second message');
|
||||
});
|
||||
|
||||
it('filters out conversations with no messages', () => {
|
||||
const data = [
|
||||
{ title: 'Empty', mapping: {} },
|
||||
{
|
||||
title: 'Has Messages',
|
||||
mapping: {
|
||||
'n1': { message: { author: { role: 'user' }, content: { parts: ['Hello'] }, create_time: 1 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = parseChatGPTExport(data);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Has Messages');
|
||||
});
|
||||
|
||||
it('handles wrapped format with conversations key', () => {
|
||||
const data = {
|
||||
conversations: [
|
||||
{
|
||||
title: 'Wrapped',
|
||||
mapping: {
|
||||
'n1': { message: { author: { role: 'user' }, content: { parts: ['Test'] }, create_time: 1 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseChatGPTExport(data);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Wrapped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude Export Parser', () => {
|
||||
it('parses conversations with chat_messages', () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'Claude Chat',
|
||||
created_at: '2024-03-01T10:00:00Z',
|
||||
chat_messages: [
|
||||
{ sender: 'human', text: 'What is TypeScript?', created_at: '2024-03-01T10:00:01Z' },
|
||||
{ sender: 'assistant', text: 'TypeScript is a superset of JavaScript.', created_at: '2024-03-01T10:00:05Z' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = parseClaudeExport(data);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Claude Chat');
|
||||
expect(result[0].messages).toHaveLength(2);
|
||||
expect(result[0].messages[0].role).toBe('user');
|
||||
expect(result[0].source).toBe('claude');
|
||||
});
|
||||
|
||||
it('handles empty exports gracefully', () => {
|
||||
expect(parseClaudeExport(null)).toEqual([]);
|
||||
expect(parseClaudeExport([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles alternative field names (title, messages, role, content)', () => {
|
||||
const data = [
|
||||
{
|
||||
title: 'Alt Format',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello from alt format', timestamp: '2024-03-01T10:00:00Z' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = parseClaudeExport(data);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Alt Format');
|
||||
expect(result[0].messages[0].text).toBe('Hello from alt format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Knowledge Extraction', () => {
|
||||
it('extracts decisions from user messages', () => {
|
||||
const convs = [{
|
||||
title: 'Tech Discussion',
|
||||
messages: [
|
||||
{ role: 'user' as const, text: 'I decided to use React for the frontend and Node.js for the backend' },
|
||||
{ role: 'assistant' as const, text: 'Great choice!' },
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const decisions = knowledge.filter(k => k.type === 'decision');
|
||||
expect(decisions.length).toBeGreaterThan(0);
|
||||
expect(decisions[0].content).toContain('decided');
|
||||
expect(decisions[0].importance).toBe('important');
|
||||
});
|
||||
|
||||
it('extracts preferences from user messages', () => {
|
||||
const convs = [{
|
||||
title: 'Preferences',
|
||||
messages: [
|
||||
{ role: 'user' as const, text: 'I prefer bullet-point summaries over long paragraphs' },
|
||||
],
|
||||
source: 'claude' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const prefs = knowledge.filter(k => k.type === 'preference');
|
||||
expect(prefs.length).toBeGreaterThan(0);
|
||||
expect(prefs[0].content).toContain('prefer');
|
||||
expect(prefs[0].importance).toBe('important');
|
||||
});
|
||||
|
||||
it('extracts facts about the user', () => {
|
||||
const convs = [{
|
||||
title: 'About me',
|
||||
messages: [
|
||||
{ role: 'user' as const, text: 'I work at Egzakta Advisory as a partner and consultant' },
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const facts = knowledge.filter(k => k.type === 'fact');
|
||||
expect(facts.length).toBeGreaterThan(0);
|
||||
expect(facts[0].content).toContain('Egzakta');
|
||||
});
|
||||
|
||||
it('extracts conversation topics', () => {
|
||||
const convs = [{
|
||||
title: 'Building a SaaS Platform',
|
||||
messages: [
|
||||
{ role: 'user' as const, text: 'Help me plan the architecture' },
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const topics = knowledge.filter(k => k.type === 'topic');
|
||||
expect(topics.length).toBeGreaterThan(0);
|
||||
expect(topics[0].content).toContain('Building a SaaS Platform');
|
||||
});
|
||||
|
||||
it('caps extraction at 100 items', () => {
|
||||
const convs = Array.from({ length: 200 }, (_, i) => ({
|
||||
title: `Conversation ${i}`,
|
||||
messages: [
|
||||
{ role: 'user' as const, text: `I decided to use approach ${i} for the implementation` },
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}));
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
expect(knowledge.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('deduplicates similar content', () => {
|
||||
const convs = [
|
||||
{
|
||||
title: 'Chat 1',
|
||||
messages: [{ role: 'user' as const, text: 'I prefer TypeScript over JavaScript for type safety' }],
|
||||
source: 'chatgpt' as const,
|
||||
},
|
||||
{
|
||||
title: 'Chat 2',
|
||||
messages: [{ role: 'user' as const, text: 'I prefer TypeScript over JavaScript for type safety' }],
|
||||
source: 'chatgpt' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const prefs = knowledge.filter(k => k.type === 'preference');
|
||||
expect(prefs.length).toBe(1); // Deduped
|
||||
});
|
||||
|
||||
it('skips very short and very long messages', () => {
|
||||
const convs = [{
|
||||
title: 'Test',
|
||||
messages: [
|
||||
{ role: 'user' as const, text: 'Hi' }, // Too short
|
||||
{ role: 'user' as const, text: 'x'.repeat(600) }, // Too long
|
||||
{ role: 'user' as const, text: 'I decided to use Python for data analysis tasks' }, // Just right
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const decisions = knowledge.filter(k => k.type === 'decision');
|
||||
expect(decisions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('skips assistant messages', () => {
|
||||
const convs = [{
|
||||
title: 'Test',
|
||||
messages: [
|
||||
{ role: 'assistant' as const, text: 'I decided to use a different approach for this solution' },
|
||||
],
|
||||
source: 'chatgpt' as const,
|
||||
}];
|
||||
|
||||
const knowledge = extractKnowledge(convs);
|
||||
const decisions = knowledge.filter(k => k.type === 'decision');
|
||||
expect(decisions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processImport (end-to-end)', () => {
|
||||
it('processes a ChatGPT export end-to-end', () => {
|
||||
const data = [
|
||||
{
|
||||
title: 'Project Planning',
|
||||
create_time: 1709000000,
|
||||
mapping: {
|
||||
'n1': { message: { author: { role: 'user' }, content: { parts: ['I decided to use React with TypeScript for the frontend'] }, create_time: 1 } },
|
||||
'n2': { message: { author: { role: 'assistant' }, content: { parts: ['Great choice!'] }, create_time: 2 } },
|
||||
'n3': { message: { author: { role: 'user' }, content: { parts: ['I prefer concise responses without filler words'] }, create_time: 3 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = processImport(data, 'chatgpt');
|
||||
expect(result.source).toBe('chatgpt');
|
||||
expect(result.conversationsFound).toBe(1);
|
||||
expect(result.conversationsParsed).toBe(1);
|
||||
expect(result.knowledgeExtracted.length).toBeGreaterThan(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
const decisions = result.knowledgeExtracted.filter(k => k.type === 'decision');
|
||||
expect(decisions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('processes a Claude export end-to-end', () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'Architecture Discussion',
|
||||
created_at: '2024-03-01T10:00:00Z',
|
||||
chat_messages: [
|
||||
{ sender: 'human', text: 'I work at Acme Corp as a senior engineer', created_at: '2024-03-01T10:00:01Z' },
|
||||
{ sender: 'assistant', text: 'Nice to meet you!', created_at: '2024-03-01T10:00:05Z' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = processImport(data, 'claude');
|
||||
expect(result.source).toBe('claude');
|
||||
expect(result.conversationsFound).toBe(1);
|
||||
expect(result.knowledgeExtracted.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns errors for invalid data', () => {
|
||||
const result = processImport('not json', 'chatgpt');
|
||||
expect(result.conversationsFound).toBe(0);
|
||||
expect(result.knowledgeExtracted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports when no conversations found', () => {
|
||||
const result = processImport([], 'chatgpt');
|
||||
expect(result.errors).toContain('No conversations found in export');
|
||||
});
|
||||
});
|
||||
125
packages/core/tests/migration.test.ts
Normal file
125
packages/core/tests/migration.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { FrameStore } from '@waggle/hive-mind-core';
|
||||
import { needsMigration, migrateToMultiMind } from '../src/migration.js';
|
||||
|
||||
describe('Migration: default.mind → personal.mind', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-migration-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('needsMigration', () => {
|
||||
it('returns true when default.mind exists and personal.mind does not', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
// Create a real MindDB so it's a valid SQLite file
|
||||
const db = new MindDB(path.join(tmpDir, 'default.mind'));
|
||||
db.close();
|
||||
|
||||
expect(needsMigration(tmpDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when personal.mind already exists', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
// Both exist
|
||||
const db1 = new MindDB(path.join(tmpDir, 'default.mind'));
|
||||
db1.close();
|
||||
const db2 = new MindDB(path.join(tmpDir, 'personal.mind'));
|
||||
db2.close();
|
||||
|
||||
expect(needsMigration(tmpDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on fresh install (nothing exists)', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
expect(needsMigration(tmpDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateToMultiMind', () => {
|
||||
it('migrates default.mind to personal.mind with data preserved', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const defaultPath = path.join(tmpDir, 'default.mind');
|
||||
|
||||
// Create a MindDB with real data
|
||||
const db = new MindDB(defaultPath);
|
||||
// Create a session (foreign key requirement)
|
||||
db.getDatabase().prepare(
|
||||
"INSERT OR IGNORE INTO sessions (gop_id, status) VALUES (?, 'active')"
|
||||
).run('test-gop');
|
||||
const frames = new FrameStore(db);
|
||||
frames.createIFrame('test-gop', 'migration test content', 'normal');
|
||||
db.close();
|
||||
|
||||
const result = migrateToMultiMind(tmpDir);
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.message).toBe('Migrated default.mind to personal.mind');
|
||||
|
||||
// Verify personal.mind has the data
|
||||
const personalPath = path.join(tmpDir, 'personal.mind');
|
||||
expect(fs.existsSync(personalPath)).toBe(true);
|
||||
|
||||
const personalDb = new MindDB(personalPath);
|
||||
const personalFrames = new FrameStore(personalDb);
|
||||
const state = personalFrames.reconstructState('test-gop');
|
||||
expect(state.iframe).not.toBeNull();
|
||||
expect(state.iframe!.content).toBe('migration test content');
|
||||
personalDb.close();
|
||||
});
|
||||
|
||||
it('keeps default.mind as backup (.bak) after migration', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const db = new MindDB(path.join(tmpDir, 'default.mind'));
|
||||
db.close();
|
||||
|
||||
migrateToMultiMind(tmpDir);
|
||||
|
||||
expect(fs.existsSync(path.join(tmpDir, 'default.mind.bak'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'default.mind'))).toBe(false);
|
||||
});
|
||||
|
||||
it('creates workspaces directory', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const db = new MindDB(path.join(tmpDir, 'default.mind'));
|
||||
db.close();
|
||||
|
||||
migrateToMultiMind(tmpDir);
|
||||
|
||||
const wsDir = path.join(tmpDir, 'workspaces');
|
||||
expect(fs.existsSync(wsDir)).toBe(true);
|
||||
expect(fs.statSync(wsDir).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not migrate twice (idempotent)', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const db = new MindDB(path.join(tmpDir, 'default.mind'));
|
||||
db.close();
|
||||
|
||||
const first = migrateToMultiMind(tmpDir);
|
||||
expect(first.migrated).toBe(true);
|
||||
|
||||
const second = migrateToMultiMind(tmpDir);
|
||||
expect(second.migrated).toBe(false);
|
||||
expect(second.message).toBe('No migration needed');
|
||||
});
|
||||
|
||||
it('returns no migration needed when nothing exists', () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const result = migrateToMultiMind(tmpDir);
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.message).toBe('No migration needed');
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/core/tests/skill-hashes.test.ts
Normal file
115
packages/core/tests/skill-hashes.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { SkillHashStore, computeSkillHash } from '../src/skill-hashes.js';
|
||||
|
||||
describe('SkillHashStore', () => {
|
||||
let tmpDir: string;
|
||||
let db: MindDB;
|
||||
let store: SkillHashStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-skill-hash-'));
|
||||
db = new MindDB(path.join(tmpDir, 'test.mind'));
|
||||
store = new SkillHashStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('setHash + getHash round-trips correctly', () => {
|
||||
const hash = computeSkillHash('# My Skill\nDo something useful');
|
||||
store.setHash('my-skill', hash);
|
||||
|
||||
const stored = store.getHash('my-skill');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.name).toBe('my-skill');
|
||||
expect(stored!.hash).toBe(hash);
|
||||
expect(stored!.verified_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('computeSkillHash produces consistent SHA-256', () => {
|
||||
const content = '# Draft Memo\nHelp the user draft professional memos.';
|
||||
const hash1 = computeSkillHash(content);
|
||||
const hash2 = computeSkillHash(content);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
// SHA-256 hex is 64 characters
|
||||
expect(hash1).toHaveLength(64);
|
||||
// Should be hex string
|
||||
expect(hash1).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('checkAll detects changed skill', () => {
|
||||
const originalContent = '# Skill v1\nOriginal content';
|
||||
store.verify('changed-skill', originalContent);
|
||||
|
||||
const result = store.checkAll([
|
||||
{ name: 'changed-skill', content: '# Skill v2\nModified content' },
|
||||
]);
|
||||
|
||||
expect(result.changed).toEqual(['changed-skill']);
|
||||
expect(result.added).toEqual([]);
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('checkAll detects new (added) skill', () => {
|
||||
const result = store.checkAll([
|
||||
{ name: 'brand-new-skill', content: '# New Skill\nFresh content' },
|
||||
]);
|
||||
|
||||
expect(result.added).toEqual(['brand-new-skill']);
|
||||
expect(result.changed).toEqual([]);
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('checkAll detects removed skill', () => {
|
||||
store.verify('old-skill', '# Old Skill\nGone now');
|
||||
|
||||
const result = store.checkAll([]);
|
||||
|
||||
expect(result.removed).toEqual(['old-skill']);
|
||||
expect(result.changed).toEqual([]);
|
||||
expect(result.added).toEqual([]);
|
||||
});
|
||||
|
||||
it('checkAll returns empty when nothing changed', () => {
|
||||
const content = '# Stable Skill\nNothing changed here';
|
||||
store.verify('stable-skill', content);
|
||||
|
||||
const result = store.checkAll([
|
||||
{ name: 'stable-skill', content },
|
||||
]);
|
||||
|
||||
expect(result.changed).toEqual([]);
|
||||
expect(result.added).toEqual([]);
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('verify updates hash to current content', () => {
|
||||
const v1 = '# Skill v1';
|
||||
const v2 = '# Skill v2';
|
||||
store.verify('evolving-skill', v1);
|
||||
|
||||
const hashBefore = store.getHash('evolving-skill')!.hash;
|
||||
expect(hashBefore).toBe(computeSkillHash(v1));
|
||||
|
||||
store.verify('evolving-skill', v2);
|
||||
|
||||
const hashAfter = store.getHash('evolving-skill')!.hash;
|
||||
expect(hashAfter).toBe(computeSkillHash(v2));
|
||||
expect(hashAfter).not.toBe(hashBefore);
|
||||
});
|
||||
|
||||
it('removeHash cleans up', () => {
|
||||
store.verify('doomed-skill', '# Doomed');
|
||||
expect(store.getHash('doomed-skill')).toBeDefined();
|
||||
|
||||
store.removeHash('doomed-skill');
|
||||
expect(store.getHash('doomed-skill')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
99
packages/core/tests/structured-tasks.test.ts
Normal file
99
packages/core/tests/structured-tasks.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB } from '@waggle/hive-mind-core';
|
||||
import { AwarenessLayer } from '@waggle/hive-mind-core';
|
||||
|
||||
describe('Structured Task Model', () => {
|
||||
let db: MindDB;
|
||||
let awareness: AwarenessLayer;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
awareness = new AwarenessLayer(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('stores task with metadata', () => {
|
||||
const item = awareness.add('task', 'Fix the login bug', 0, undefined, {
|
||||
status: 'pending',
|
||||
context: 'auth module',
|
||||
priority: 'high',
|
||||
});
|
||||
expect(item).toBeDefined();
|
||||
expect(item.content).toBe('Fix the login bug');
|
||||
const meta = awareness.parseMetadata(item);
|
||||
expect(meta.status).toBe('pending');
|
||||
expect(meta.context).toBe('auth module');
|
||||
expect(meta.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('stores task without metadata (defaults to empty object)', () => {
|
||||
const item = awareness.add('task', 'Simple task');
|
||||
expect(item.metadata).toBe('{}');
|
||||
const meta = awareness.parseMetadata(item);
|
||||
expect(meta).toEqual({});
|
||||
});
|
||||
|
||||
it('updates metadata on existing task', () => {
|
||||
const item = awareness.add('task', 'Deploy v2.0', 0, undefined, { status: 'pending' });
|
||||
awareness.updateMetadata(item.id, { status: 'in_progress', result: 'deploying...' });
|
||||
const updated = awareness.get(item.id);
|
||||
expect(updated).toBeDefined();
|
||||
const meta = awareness.parseMetadata(updated!);
|
||||
expect(meta.status).toBe('in_progress');
|
||||
expect(meta.result).toBe('deploying...');
|
||||
});
|
||||
|
||||
it('merges metadata without losing existing fields', () => {
|
||||
const item = awareness.add('task', 'Multi-step task', 0, undefined, {
|
||||
status: 'pending',
|
||||
context: 'deployment',
|
||||
});
|
||||
awareness.updateMetadata(item.id, { status: 'in_progress' });
|
||||
const updated = awareness.get(item.id);
|
||||
const meta = awareness.parseMetadata(updated!);
|
||||
expect(meta.status).toBe('in_progress');
|
||||
expect(meta.context).toBe('deployment'); // preserved
|
||||
});
|
||||
|
||||
it('throws when updating metadata for nonexistent item', () => {
|
||||
expect(() => awareness.updateMetadata(999, { status: 'done' })).toThrow('Awareness item 999 not found');
|
||||
});
|
||||
|
||||
it('retrieves tasks by status', () => {
|
||||
awareness.add('task', 'Task A', 0, undefined, { status: 'pending' });
|
||||
awareness.add('task', 'Task B', 0, undefined, { status: 'done' });
|
||||
awareness.add('task', 'Task C', 0, undefined, { status: 'pending' });
|
||||
const pending = awareness.getByStatus('pending');
|
||||
expect(pending.length).toBe(2);
|
||||
expect(pending.every(i => awareness.parseMetadata(i).status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
it('getByStatus returns empty array when no matches', () => {
|
||||
awareness.add('task', 'Task A', 0, undefined, { status: 'pending' });
|
||||
const inProgress = awareness.getByStatus('in_progress');
|
||||
expect(inProgress).toEqual([]);
|
||||
});
|
||||
|
||||
it('getByStatus ignores items without metadata status', () => {
|
||||
awareness.add('task', 'No metadata task');
|
||||
awareness.add('task', 'Has status', 0, undefined, { status: 'pending' });
|
||||
const pending = awareness.getByStatus('pending');
|
||||
expect(pending.length).toBe(1);
|
||||
});
|
||||
|
||||
it('get() retrieves a single item by id', () => {
|
||||
const item = awareness.add('task', 'Find me', 5);
|
||||
const found = awareness.get(item.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.content).toBe('Find me');
|
||||
expect(found!.priority).toBe(5);
|
||||
});
|
||||
|
||||
it('get() returns undefined for nonexistent id', () => {
|
||||
const found = awareness.get(999);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
297
packages/core/tests/team-sync.test.ts
Normal file
297
packages/core/tests/team-sync.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { frameToEntity, entityToSyncedFrame, TeamSync, type TeamSyncConfig } from '../src/team-sync.js';
|
||||
import type { MemoryFrame } from '@waggle/hive-mind-core';
|
||||
|
||||
describe('frameToEntity', () => {
|
||||
it('converts a MemoryFrame to team entity format', () => {
|
||||
const frame: MemoryFrame = {
|
||||
id: 42,
|
||||
frame_type: 'I',
|
||||
gop_id: 'project-context',
|
||||
t: 3,
|
||||
base_frame_id: null,
|
||||
content: 'The project uses React and TypeScript',
|
||||
importance: 'important',
|
||||
access_count: 5,
|
||||
created_at: '2026-03-12T10:00:00.000Z',
|
||||
last_accessed: '2026-03-12T12:00:00.000Z',
|
||||
};
|
||||
|
||||
const entity = frameToEntity(frame, 'user-abc', 'Marko');
|
||||
|
||||
expect(entity.entityType).toBe('memory_frame');
|
||||
expect(entity.name).toBe('project-context');
|
||||
expect(entity.properties.frameType).toBe('I');
|
||||
expect(entity.properties.t).toBe(3);
|
||||
expect(entity.properties.baseFrameId).toBeNull();
|
||||
expect(entity.properties.content).toBe('The project uses React and TypeScript');
|
||||
expect(entity.properties.importance).toBe('important');
|
||||
expect(entity.properties.authorId).toBe('user-abc');
|
||||
expect(entity.properties.authorName).toBe('Marko');
|
||||
expect(entity.properties.localId).toBe(42);
|
||||
});
|
||||
|
||||
it('preserves P-frame base_frame_id', () => {
|
||||
const frame: MemoryFrame = {
|
||||
id: 43,
|
||||
frame_type: 'P',
|
||||
gop_id: 'project-context',
|
||||
t: 4,
|
||||
base_frame_id: 42,
|
||||
content: 'Updated: now also using Tailwind',
|
||||
importance: 'normal',
|
||||
access_count: 0,
|
||||
created_at: '2026-03-12T11:00:00.000Z',
|
||||
last_accessed: '2026-03-12T11:00:00.000Z',
|
||||
};
|
||||
|
||||
const entity = frameToEntity(frame, 'user-xyz', 'Ana');
|
||||
|
||||
expect(entity.properties.frameType).toBe('P');
|
||||
expect(entity.properties.baseFrameId).toBe(42);
|
||||
expect(entity.properties.authorName).toBe('Ana');
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityToSyncedFrame', () => {
|
||||
it('converts a team entity back to SyncedFrame', () => {
|
||||
const entity = {
|
||||
id: 'uuid-remote-1',
|
||||
name: 'project-context',
|
||||
properties: {
|
||||
frameType: 'I',
|
||||
t: 3,
|
||||
baseFrameId: null,
|
||||
content: 'The project uses React and TypeScript',
|
||||
importance: 'important',
|
||||
authorId: 'user-abc',
|
||||
authorName: 'Marko',
|
||||
localId: 42,
|
||||
},
|
||||
createdAt: '2026-03-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const frame = entityToSyncedFrame(entity);
|
||||
|
||||
expect(frame.remoteId).toBe('uuid-remote-1');
|
||||
expect(frame.gopId).toBe('project-context');
|
||||
expect(frame.t).toBe(3);
|
||||
expect(frame.frameType).toBe('I');
|
||||
expect(frame.content).toBe('The project uses React and TypeScript');
|
||||
expect(frame.importance).toBe('important');
|
||||
expect(frame.authorId).toBe('user-abc');
|
||||
expect(frame.authorName).toBe('Marko');
|
||||
expect(frame.createdAt).toBe('2026-03-12T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('handles missing properties gracefully', () => {
|
||||
const entity = {
|
||||
id: 'uuid-remote-2',
|
||||
name: 'some-gop',
|
||||
properties: {},
|
||||
createdAt: '2026-03-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const frame = entityToSyncedFrame(entity);
|
||||
|
||||
expect(frame.remoteId).toBe('uuid-remote-2');
|
||||
expect(frame.gopId).toBe('some-gop');
|
||||
expect(frame.t).toBe(0);
|
||||
expect(frame.frameType).toBe('I');
|
||||
expect(frame.content).toBe('');
|
||||
expect(frame.importance).toBe('normal');
|
||||
expect(frame.authorId).toBe('');
|
||||
expect(frame.authorName).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamSync', () => {
|
||||
const mockConfig: TeamSyncConfig = {
|
||||
teamServerUrl: 'https://team.waggle.dev',
|
||||
teamSlug: 'test-team',
|
||||
authToken: 'test-jwt-token',
|
||||
userId: 'user-abc',
|
||||
displayName: 'Marko',
|
||||
};
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pushFrame', () => {
|
||||
it('sends frame to team server entities endpoint', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'remote-uuid-1' }),
|
||||
});
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frame: MemoryFrame = {
|
||||
id: 1,
|
||||
frame_type: 'I',
|
||||
gop_id: 'test-gop',
|
||||
t: 0,
|
||||
base_frame_id: null,
|
||||
content: 'Test content',
|
||||
importance: 'normal',
|
||||
access_count: 0,
|
||||
created_at: '2026-03-12T10:00:00.000Z',
|
||||
last_accessed: '2026-03-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const result = await sync.pushFrame(frame);
|
||||
|
||||
expect(result).toEqual({ remoteId: 'remote-uuid-1' });
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
|
||||
const [url, opts] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('https://team.waggle.dev/api/teams/test-team/entities');
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.headers['Authorization']).toBe('Bearer test-jwt-token');
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.entityType).toBe('memory_frame');
|
||||
expect(body.name).toBe('test-gop');
|
||||
expect(body.properties.content).toBe('Test content');
|
||||
expect(body.properties.authorId).toBe('user-abc');
|
||||
expect(body.properties.authorName).toBe('Marko');
|
||||
});
|
||||
|
||||
it('returns null on server error', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frame: MemoryFrame = {
|
||||
id: 1, frame_type: 'I', gop_id: 'test', t: 0, base_frame_id: null,
|
||||
content: 'x', importance: 'normal', access_count: 0,
|
||||
created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const result = await sync.pushFrame(frame);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on network error', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('Network unreachable'));
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frame: MemoryFrame = {
|
||||
id: 1, frame_type: 'I', gop_id: 'test', t: 0, base_frame_id: null,
|
||||
content: 'x', importance: 'normal', access_count: 0,
|
||||
created_at: '2026-03-12T10:00:00.000Z', last_accessed: '2026-03-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const result = await sync.pushFrame(frame);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullFrames', () => {
|
||||
it('fetches frames from team server', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ([
|
||||
{
|
||||
id: 'uuid-1',
|
||||
name: 'gop-a',
|
||||
properties: { frameType: 'I', t: 0, content: 'First', importance: 'important', authorId: 'u1', authorName: 'Marko' },
|
||||
createdAt: '2026-03-12T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'uuid-2',
|
||||
name: 'gop-b',
|
||||
properties: { frameType: 'P', t: 1, content: 'Second', importance: 'normal', authorId: 'u2', authorName: 'Ana' },
|
||||
createdAt: '2026-03-12T11:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frames = await sync.pullFrames();
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[0].gopId).toBe('gop-a');
|
||||
expect(frames[0].authorName).toBe('Marko');
|
||||
expect(frames[1].gopId).toBe('gop-b');
|
||||
expect(frames[1].authorName).toBe('Ana');
|
||||
|
||||
const [url, opts] = fetchSpy.mock.calls[0];
|
||||
expect(url).toContain('/api/teams/test-team/entities?type=memory_frame');
|
||||
expect(opts.headers['Authorization']).toBe('Bearer test-jwt-token');
|
||||
});
|
||||
|
||||
it('filters by since timestamp when provided', async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ([
|
||||
{
|
||||
id: 'uuid-1', name: 'gop-a',
|
||||
properties: { frameType: 'I', t: 0, content: 'Old', authorId: 'u1', authorName: 'M' },
|
||||
createdAt: '2026-03-11T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'uuid-2', name: 'gop-b',
|
||||
properties: { frameType: 'I', t: 0, content: 'New', authorId: 'u2', authorName: 'A' },
|
||||
createdAt: '2026-03-12T15:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frames = await sync.pullFrames('2026-03-12T00:00:00.000Z');
|
||||
|
||||
// Only the frame after the since timestamp
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0].content).toBe('New');
|
||||
});
|
||||
|
||||
it('returns empty array on server error', async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: false, status: 500, statusText: 'Error' });
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frames = await sync.pullFrames();
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array on network error', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('Offline'));
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
const frames = await sync.pullFrames();
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync timestamp tracking', () => {
|
||||
it('starts with null timestamp', () => {
|
||||
const sync = new TeamSync(mockConfig);
|
||||
expect(sync.getLastSyncTimestamp()).toBeNull();
|
||||
});
|
||||
|
||||
it('updates timestamp after successful pull', async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: true, json: async () => ([]) });
|
||||
|
||||
const sync = new TeamSync(mockConfig);
|
||||
await sync.pullFrames();
|
||||
|
||||
expect(sync.getLastSyncTimestamp()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows manual timestamp setting', () => {
|
||||
const sync = new TeamSync(mockConfig);
|
||||
sync.setLastSyncTimestamp('2026-03-12T10:00:00.000Z');
|
||||
expect(sync.getLastSyncTimestamp()).toBe('2026-03-12T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
112
packages/core/tests/telemetry.test.ts
Normal file
112
packages/core/tests/telemetry.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TelemetryCollector } from '../src/telemetry.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('TelemetryCollector', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = path.join(os.tmpdir(), `waggle-telemetry-test-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('records events when enabled', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordToolUse('web_search');
|
||||
collector.recordToolUse('web_search');
|
||||
collector.recordToolUse('save_memory');
|
||||
|
||||
const report = collector.getReport();
|
||||
expect(report.totalEvents).toBe(3);
|
||||
const webSearch = report.events.find(e => e.name === 'web_search');
|
||||
expect(webSearch?.count).toBe(2);
|
||||
});
|
||||
|
||||
it('silently drops events when disabled', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, false);
|
||||
collector.recordToolUse('web_search');
|
||||
collector.recordCommand('/research');
|
||||
collector.recordError('timeout');
|
||||
|
||||
const report = collector.getReport();
|
||||
expect(report.totalEvents).toBe(0);
|
||||
});
|
||||
|
||||
it('no PII in collected data — tool names only', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordToolUse('connector_github_create_issue');
|
||||
collector.recordError('api_timeout');
|
||||
collector.recordCapabilityGap('email sending');
|
||||
|
||||
const report = collector.getReport();
|
||||
for (const event of report.events) {
|
||||
// Only category, name, count, date — no message content or file paths
|
||||
expect(Object.keys(event).sort()).toEqual(['category', 'count', 'date', 'name']);
|
||||
}
|
||||
});
|
||||
|
||||
it('daily aggregation: same-day events merge counts', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordToolUse('bash');
|
||||
collector.recordToolUse('bash');
|
||||
collector.recordToolUse('bash');
|
||||
|
||||
const report = collector.getReport();
|
||||
const bashEvents = report.events.filter(e => e.name === 'bash');
|
||||
expect(bashEvents).toHaveLength(1);
|
||||
expect(bashEvents[0].count).toBe(3);
|
||||
});
|
||||
|
||||
it('flush writes to telemetry.json', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordToolUse('search_memory');
|
||||
collector.flush();
|
||||
|
||||
const filePath = path.join(tmpDir, 'telemetry.json');
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].name).toBe('search_memory');
|
||||
|
||||
collector.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('report covers last N days', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordToolUse('today_tool');
|
||||
|
||||
const report = collector.getReport(7);
|
||||
expect(report.events.length).toBeGreaterThan(0);
|
||||
expect(report.dateRange.from).toBeTruthy();
|
||||
});
|
||||
|
||||
it('setEnabled toggles collection', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, false);
|
||||
expect(collector.isEnabled()).toBe(false);
|
||||
|
||||
collector.setEnabled(true);
|
||||
expect(collector.isEnabled()).toBe(true);
|
||||
|
||||
collector.recordToolUse('test_tool');
|
||||
expect(collector.getReport().totalEvents).toBe(1);
|
||||
});
|
||||
|
||||
it('recordSession aggregates duration and interaction count', () => {
|
||||
const collector = new TelemetryCollector(tmpDir, true);
|
||||
collector.recordSession(30000, 15);
|
||||
collector.recordSession(20000, 10);
|
||||
|
||||
const report = collector.getReport();
|
||||
const duration = report.events.find(e => e.name === 'duration_total_ms');
|
||||
const interactions = report.events.find(e => e.name === 'interaction_count');
|
||||
const sessions = report.events.find(e => e.name === 'session_count');
|
||||
|
||||
expect(duration?.count).toBe(50000);
|
||||
expect(interactions?.count).toBe(25);
|
||||
expect(sessions?.count).toBe(2);
|
||||
});
|
||||
});
|
||||
136
packages/core/tests/vault-concurrency.test.ts
Normal file
136
packages/core/tests/vault-concurrency.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { VaultStore } from '../src/vault.js';
|
||||
|
||||
describe('Vault Concurrency & Atomic Writes (11B-8)', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-vault-conc-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('5 concurrent setAsync calls — all 5 keys exist after', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Fire 5 concurrent writes
|
||||
await Promise.all([
|
||||
vault.setAsync('key-1', 'value-1'),
|
||||
vault.setAsync('key-2', 'value-2'),
|
||||
vault.setAsync('key-3', 'value-3'),
|
||||
vault.setAsync('key-4', 'value-4'),
|
||||
vault.setAsync('key-5', 'value-5'),
|
||||
]);
|
||||
|
||||
// Verify all 5 keys exist and have correct values
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const entry = vault.get(`key-${i}`);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe(`value-${i}`);
|
||||
}
|
||||
|
||||
// Verify via list that all 5 are present
|
||||
const list = vault.list();
|
||||
expect(list).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('concurrent setAsync and deleteAsync do not corrupt vault', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Set some initial keys
|
||||
vault.set('keep-1', 'v1');
|
||||
vault.set('remove-1', 'v2');
|
||||
vault.set('keep-2', 'v3');
|
||||
|
||||
// Concurrently set new keys and delete existing ones
|
||||
await Promise.all([
|
||||
vault.setAsync('new-1', 'new-v1'),
|
||||
vault.deleteAsync('remove-1'),
|
||||
vault.setAsync('new-2', 'new-v2'),
|
||||
]);
|
||||
|
||||
expect(vault.get('keep-1')?.value).toBe('v1');
|
||||
expect(vault.get('keep-2')?.value).toBe('v3');
|
||||
expect(vault.get('new-1')?.value).toBe('new-v1');
|
||||
expect(vault.get('new-2')?.value).toBe('new-v2');
|
||||
expect(vault.get('remove-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('atomic write — no vault.json.tmp left behind after write', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('test', 'value');
|
||||
|
||||
const tmpPath = path.join(dir, 'vault.json.tmp');
|
||||
expect(fs.existsSync(tmpPath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, 'vault.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteAsync returns correct existed flag', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('exists', 'val');
|
||||
|
||||
const existed = await vault.deleteAsync('exists');
|
||||
expect(existed).toBe(true);
|
||||
|
||||
const notExisted = await vault.deleteAsync('never-was');
|
||||
expect(notExisted).toBe(false);
|
||||
});
|
||||
|
||||
it('setAsync overwrites existing key', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('overwrite-me', 'old-value');
|
||||
await vault.setAsync('overwrite-me', 'new-value');
|
||||
|
||||
const entry = vault.get('overwrite-me');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('new-value');
|
||||
});
|
||||
|
||||
it('10 concurrent setAsync calls on same key — last one wins', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Chain them — since writeLock serializes, the last .then() should win
|
||||
const promises: Promise<void>[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(vault.setAsync('contested', `value-${i}`));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const entry = vault.get('contested');
|
||||
expect(entry).not.toBeNull();
|
||||
// The value should be the last one written (value-9)
|
||||
// because the write lock serializes them in order
|
||||
expect(entry!.value).toBe('value-9');
|
||||
});
|
||||
|
||||
it('sync set still works correctly (backward compatibility)', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('sync-key', 'sync-value', { tag: 'test' });
|
||||
const entry = vault.get('sync-key');
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('sync-value');
|
||||
expect(entry!.metadata).toEqual({ tag: 'test' });
|
||||
});
|
||||
});
|
||||
261
packages/core/tests/vault-edge-cases.test.ts
Normal file
261
packages/core/tests/vault-edge-cases.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Vault edge case tests — corrupted files, missing files,
|
||||
* concurrent read/write safety, and large value storage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { VaultStore } from '../src/vault.js';
|
||||
|
||||
describe('VaultStore edge cases', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-vault-edge-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
// ── Corrupted vault.json ────────────────────────────────────────────
|
||||
|
||||
describe('corrupted vault.json', () => {
|
||||
it('handles invalid JSON gracefully — returns null for get', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Store a valid secret first
|
||||
vault.set('test-key', 'test-value');
|
||||
expect(vault.get('test-key')!.value).toBe('test-value');
|
||||
|
||||
// Corrupt the vault.json file with invalid JSON
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
fs.writeFileSync(vaultPath, '{ this is not valid JSON !!!', 'utf-8');
|
||||
|
||||
// get should return null (not crash)
|
||||
expect(vault.get('test-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully — list returns empty array', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Corrupt the vault.json file
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
fs.writeFileSync(vaultPath, '<xml>not json</xml>', 'utf-8');
|
||||
|
||||
// list should return empty (not crash)
|
||||
expect(vault.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully — has returns false', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Corrupt the vault.json file
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
fs.writeFileSync(vaultPath, '}}broken{{', 'utf-8');
|
||||
|
||||
// has should return false (not crash)
|
||||
expect(vault.has('anything')).toBe(false);
|
||||
});
|
||||
|
||||
it('can write new secrets after corruption — overwrites corrupted file', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Corrupt the vault.json file
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
fs.writeFileSync(vaultPath, 'CORRUPT!', 'utf-8');
|
||||
|
||||
// set should overwrite corrupted file with valid data
|
||||
vault.set('recovery-key', 'recovered-value');
|
||||
|
||||
// Should be able to read back the new value
|
||||
const entry = vault.get('recovery-key');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('recovered-value');
|
||||
|
||||
// Verify the file is now valid JSON
|
||||
const raw = fs.readFileSync(vaultPath, 'utf-8');
|
||||
expect(() => JSON.parse(raw)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Missing vault.json ──────────────────────────────────────────────
|
||||
|
||||
describe('missing vault.json', () => {
|
||||
it('creates a new vault.json on next write when file is missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
|
||||
// Ensure no vault.json exists initially
|
||||
if (fs.existsSync(vaultPath)) {
|
||||
fs.unlinkSync(vaultPath);
|
||||
}
|
||||
expect(fs.existsSync(vaultPath)).toBe(false);
|
||||
|
||||
// Write a secret — should create vault.json
|
||||
vault.set('new-key', 'new-value');
|
||||
|
||||
expect(fs.existsSync(vaultPath)).toBe(true);
|
||||
const entry = vault.get('new-key');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('new-value');
|
||||
});
|
||||
|
||||
it('get returns null when vault.json does not exist', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
|
||||
// Ensure no vault.json exists
|
||||
if (fs.existsSync(vaultPath)) {
|
||||
fs.unlinkSync(vaultPath);
|
||||
}
|
||||
|
||||
expect(vault.get('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('list returns empty array when vault.json does not exist', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const vaultPath = path.join(dir, 'vault.json');
|
||||
|
||||
// Ensure no vault.json exists
|
||||
if (fs.existsSync(vaultPath)) {
|
||||
fs.unlinkSync(vaultPath);
|
||||
}
|
||||
|
||||
expect(vault.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent reads during write ──────────────────────────────────
|
||||
|
||||
describe('concurrent reads during write', () => {
|
||||
it('read during setAsync does not corrupt data', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Pre-populate with a known value
|
||||
vault.set('existing', 'original-value');
|
||||
|
||||
// Start an async write, then immediately read
|
||||
const writePromise = vault.setAsync('new-key', 'new-value');
|
||||
const readResult = vault.get('existing');
|
||||
|
||||
await writePromise;
|
||||
|
||||
// The original key should still be readable (not corrupted)
|
||||
expect(readResult).not.toBeNull();
|
||||
expect(readResult!.value).toBe('original-value');
|
||||
|
||||
// The new key should also be present
|
||||
const newEntry = vault.get('new-key');
|
||||
expect(newEntry).not.toBeNull();
|
||||
expect(newEntry!.value).toBe('new-value');
|
||||
|
||||
// The existing key should still be intact
|
||||
const existingAfter = vault.get('existing');
|
||||
expect(existingAfter).not.toBeNull();
|
||||
expect(existingAfter!.value).toBe('original-value');
|
||||
});
|
||||
|
||||
it('multiple rapid reads interleaved with writes produce consistent results', async () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Write initial data
|
||||
vault.set('stable', 'stable-value');
|
||||
|
||||
// Fire off writes and reads in rapid succession
|
||||
const writePromises = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
writePromises.push(vault.setAsync(`rapid-${i}`, `value-${i}`));
|
||||
}
|
||||
|
||||
// Interleave reads
|
||||
const readResults: (string | null)[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const entry = vault.get('stable');
|
||||
readResults.push(entry?.value ?? null);
|
||||
}
|
||||
|
||||
await Promise.all(writePromises);
|
||||
|
||||
// All reads of the stable key should return the correct value
|
||||
for (const val of readResults) {
|
||||
expect(val).toBe('stable-value');
|
||||
}
|
||||
|
||||
// All written keys should be present
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const entry = vault.get(`rapid-${i}`);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe(`value-${i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Large value storage ────────────────────────────────────────────
|
||||
|
||||
describe('large value storage', () => {
|
||||
it('stores and retrieves a 1MB string correctly', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Generate a 1MB string (1,048,576 characters)
|
||||
const largeValue = 'A'.repeat(1024 * 1024);
|
||||
expect(largeValue.length).toBe(1024 * 1024);
|
||||
|
||||
vault.set('large-secret', largeValue);
|
||||
|
||||
const entry = vault.get('large-secret');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe(largeValue);
|
||||
expect(entry!.value.length).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
it('large value is encrypted in vault.json — plaintext not present', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// Use a distinctive pattern that would be easy to find if unencrypted
|
||||
const largeValue = 'SECRET_MARKER_'.repeat(10000);
|
||||
vault.set('large-encrypted', largeValue);
|
||||
|
||||
const rawContent = fs.readFileSync(path.join(dir, 'vault.json'), 'utf-8');
|
||||
expect(rawContent).not.toContain('SECRET_MARKER_');
|
||||
|
||||
// Verify the encrypted field has the correct format
|
||||
const parsed = JSON.parse(rawContent);
|
||||
expect(parsed['large-encrypted'].encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('large value survives round-trip through different VaultStore instances', () => {
|
||||
const dir = makeTempDir();
|
||||
|
||||
// Write with one instance
|
||||
const vault1 = new VaultStore(dir);
|
||||
const largeValue = 'B'.repeat(1024 * 1024);
|
||||
vault1.set('large-roundtrip', largeValue);
|
||||
|
||||
// Read with a different instance (same key file)
|
||||
const vault2 = new VaultStore(dir);
|
||||
const entry = vault2.get('large-roundtrip');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe(largeValue);
|
||||
expect(entry!.value.length).toBe(1024 * 1024);
|
||||
});
|
||||
});
|
||||
});
|
||||
391
packages/core/tests/vault.test.ts
Normal file
391
packages/core/tests/vault.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
// Hoisted mock for node:child_process so static imports in vault.ts are intercepted.
|
||||
// Defaults to the real implementation; individual tests override via mockImplementation.
|
||||
const mockExecFileSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return { ...actual, execFileSync: mockExecFileSync };
|
||||
});
|
||||
|
||||
import { VaultStore, type VaultEntry } from '../src/vault.js';
|
||||
|
||||
describe('VaultStore', () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-vault-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('set and get — store a secret, retrieve it, value matches', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('anthropic', 'sk-ant-secret-key-123');
|
||||
|
||||
const entry = vault.get('anthropic');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.name).toBe('anthropic');
|
||||
expect(entry!.value).toBe('sk-ant-secret-key-123');
|
||||
expect(entry!.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('set overwrites — set same name twice, get returns latest', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('openai', 'sk-old-key');
|
||||
vault.set('openai', 'sk-new-key');
|
||||
|
||||
const entry = vault.get('openai');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('sk-new-key');
|
||||
});
|
||||
|
||||
it('get nonexistent — returns null', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
expect(vault.get('doesnotexist')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete — removes secret, get returns null after', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('anthropic', 'sk-ant-key');
|
||||
expect(vault.delete('anthropic')).toBe(true);
|
||||
expect(vault.get('anthropic')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete nonexistent — returns false', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
expect(vault.delete('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('list — shows names + metadata without values', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('anthropic', 'sk-ant-key', { models: ['claude-sonnet-4-6'] });
|
||||
vault.set('openai', 'sk-openai-key', { models: ['gpt-4o'], baseUrl: 'https://api.openai.com' });
|
||||
|
||||
const entries = vault.list();
|
||||
expect(entries).toHaveLength(2);
|
||||
|
||||
const names = entries.map(e => e.name);
|
||||
expect(names).toContain('anthropic');
|
||||
expect(names).toContain('openai');
|
||||
|
||||
// list must NOT contain secret values
|
||||
for (const entry of entries) {
|
||||
expect(entry).not.toHaveProperty('value');
|
||||
expect(entry.updatedAt).toBeTruthy();
|
||||
}
|
||||
|
||||
const anthropicEntry = entries.find(e => e.name === 'anthropic')!;
|
||||
expect(anthropicEntry.metadata).toEqual({ models: ['claude-sonnet-4-6'] });
|
||||
});
|
||||
|
||||
it('has — returns true for existing, false for missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.set('anthropic', 'sk-ant-key');
|
||||
|
||||
expect(vault.has('anthropic')).toBe(true);
|
||||
expect(vault.has('missing')).toBe(false);
|
||||
});
|
||||
|
||||
it('encryption is real — vault.json does NOT contain plaintext value', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const secret = 'sk-ant-super-secret-api-key-12345';
|
||||
|
||||
vault.set('anthropic', secret);
|
||||
|
||||
const rawContent = fs.readFileSync(path.join(dir, 'vault.json'), 'utf-8');
|
||||
expect(rawContent).not.toContain(secret);
|
||||
|
||||
// The encrypted field should exist and contain hex data with colons (iv:tag:ciphertext)
|
||||
const parsed = JSON.parse(rawContent);
|
||||
expect(parsed.anthropic.encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('different VaultStore instances with same key can decrypt', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault1 = new VaultStore(dir);
|
||||
vault1.set('anthropic', 'sk-ant-shared-secret');
|
||||
|
||||
// Create a second instance pointing at the same directory (same key file)
|
||||
const vault2 = new VaultStore(dir);
|
||||
const entry = vault2.get('anthropic');
|
||||
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.value).toBe('sk-ant-shared-secret');
|
||||
});
|
||||
|
||||
it('migration from config — providers migrated to vault', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
const config = {
|
||||
providers: {
|
||||
anthropic: { apiKey: 'sk-ant-key-1', models: ['claude-sonnet-4-6'], baseUrl: undefined },
|
||||
openai: { apiKey: 'sk-openai-key-1', models: ['gpt-4o'] },
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = vault.migrateFromConfig(config);
|
||||
expect(migrated).toBe(2);
|
||||
|
||||
const anthropic = vault.get('anthropic');
|
||||
expect(anthropic).not.toBeNull();
|
||||
expect(anthropic!.value).toBe('sk-ant-key-1');
|
||||
expect(anthropic!.metadata?.models).toEqual(['claude-sonnet-4-6']);
|
||||
|
||||
const openai = vault.get('openai');
|
||||
expect(openai).not.toBeNull();
|
||||
expect(openai!.value).toBe('sk-openai-key-1');
|
||||
});
|
||||
|
||||
it('migration skips existing — migrate twice, count stays same', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
const config = {
|
||||
providers: {
|
||||
anthropic: { apiKey: 'sk-ant-key-1', models: ['claude-sonnet-4-6'] },
|
||||
},
|
||||
};
|
||||
|
||||
const first = vault.migrateFromConfig(config);
|
||||
expect(first).toBe(1);
|
||||
|
||||
const second = vault.migrateFromConfig(config);
|
||||
expect(second).toBe(0);
|
||||
});
|
||||
|
||||
it('key file is generated on first use and reused', () => {
|
||||
const dir = makeTempDir();
|
||||
new VaultStore(dir);
|
||||
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
expect(fs.existsSync(keyPath)).toBe(true);
|
||||
|
||||
// Key should be 64 hex chars (32 bytes)
|
||||
const keyHex = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
expect(keyHex).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// Second instance should use the same key (not overwrite)
|
||||
new VaultStore(dir);
|
||||
const keyHex2 = fs.readFileSync(keyPath, 'utf-8').trim();
|
||||
expect(keyHex2).toBe(keyHex);
|
||||
});
|
||||
|
||||
it('setConnectorCredential — refresh token is NOT stored as plaintext in vault.json', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const refreshToken = 'rt-super-secret-refresh-token-xyz';
|
||||
|
||||
vault.setConnectorCredential('github', {
|
||||
type: 'oauth2',
|
||||
value: 'gho_access_token_123',
|
||||
refreshToken,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
scopes: ['repo', 'user'],
|
||||
});
|
||||
|
||||
// Read raw vault.json and verify the refresh token is NOT in plaintext
|
||||
const rawContent = fs.readFileSync(path.join(dir, 'vault.json'), 'utf-8');
|
||||
expect(rawContent).not.toContain(refreshToken);
|
||||
|
||||
// The metadata for the main connector entry must NOT contain refreshToken
|
||||
const parsed = JSON.parse(rawContent);
|
||||
const mainEntry = parsed['connector:github'];
|
||||
expect(mainEntry).toBeDefined();
|
||||
expect(mainEntry.metadata).not.toHaveProperty('refreshToken');
|
||||
|
||||
// The refresh token should be stored as a separate encrypted entry
|
||||
const refreshEntry = parsed['connector:github:refresh'];
|
||||
expect(refreshEntry).toBeDefined();
|
||||
expect(refreshEntry.encrypted).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('getConnectorCredential — returns decrypted refresh token correctly', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
const accessToken = 'gho_access_token_456';
|
||||
const refreshToken = 'rt-secret-refresh-token-abc';
|
||||
|
||||
vault.setConnectorCredential('github', {
|
||||
type: 'oauth2',
|
||||
value: accessToken,
|
||||
refreshToken,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
scopes: ['repo'],
|
||||
});
|
||||
|
||||
const cred = vault.getConnectorCredential('github');
|
||||
expect(cred).not.toBeNull();
|
||||
expect(cred!.value).toBe(accessToken);
|
||||
expect(cred!.refreshToken).toBe(refreshToken);
|
||||
expect(cred!.type).toBe('oauth2');
|
||||
expect(cred!.expiresAt).toBe('2099-01-01T00:00:00Z');
|
||||
expect(cred!.scopes).toEqual(['repo']);
|
||||
expect(cred!.isExpired).toBe(false);
|
||||
});
|
||||
|
||||
it('setConnectorCredential — without refresh token does not create refresh entry', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
vault.setConnectorCredential('slack', {
|
||||
type: 'bearer',
|
||||
value: 'xoxb-token-123',
|
||||
});
|
||||
|
||||
const rawContent = fs.readFileSync(path.join(dir, 'vault.json'), 'utf-8');
|
||||
const parsed = JSON.parse(rawContent);
|
||||
expect(parsed['connector:slack']).toBeDefined();
|
||||
expect(parsed['connector:slack:refresh']).toBeUndefined();
|
||||
|
||||
const cred = vault.getConnectorCredential('slack');
|
||||
expect(cred).not.toBeNull();
|
||||
expect(cred!.value).toBe('xoxb-token-123');
|
||||
expect(cred!.refreshToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setConnectorCredential — clears refresh token when re-set without one', () => {
|
||||
const dir = makeTempDir();
|
||||
const vault = new VaultStore(dir);
|
||||
|
||||
// First set with refresh token
|
||||
vault.setConnectorCredential('github', {
|
||||
type: 'oauth2',
|
||||
value: 'gho_token_1',
|
||||
refreshToken: 'rt-old-refresh',
|
||||
});
|
||||
expect(vault.getConnectorCredential('github')!.refreshToken).toBe('rt-old-refresh');
|
||||
|
||||
// Re-set without refresh token
|
||||
vault.setConnectorCredential('github', {
|
||||
type: 'oauth2',
|
||||
value: 'gho_token_2',
|
||||
});
|
||||
const cred = vault.getConnectorCredential('github');
|
||||
expect(cred!.value).toBe('gho_token_2');
|
||||
expect(cred!.refreshToken).toBeUndefined();
|
||||
expect(vault.has('connector:github:refresh')).toBe(false);
|
||||
});
|
||||
|
||||
it('corrupted key file — truncated hex throws clear error', () => {
|
||||
const dir = makeTempDir();
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
|
||||
// Write a truncated key (only 10 hex chars = 5 bytes instead of 32)
|
||||
fs.writeFileSync(keyPath, 'abcdef0123', { mode: 0o600 });
|
||||
|
||||
expect(() => new VaultStore(dir)).toThrowError(
|
||||
/Vault key file is corrupted — expected 32 bytes, got 5/
|
||||
);
|
||||
});
|
||||
|
||||
it('corrupted key file — non-hex content throws clear error', () => {
|
||||
const dir = makeTempDir();
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
|
||||
// Non-hex content: Buffer.from('not-hex-at-all', 'hex') silently produces a short buffer
|
||||
fs.writeFileSync(keyPath, 'not-hex-at-all-garbage-content', { mode: 0o600 });
|
||||
|
||||
expect(() => new VaultStore(dir)).toThrowError(
|
||||
/Vault key file is corrupted — expected 32 bytes/
|
||||
);
|
||||
});
|
||||
|
||||
it('corrupted key file — empty file throws clear error', () => {
|
||||
const dir = makeTempDir();
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
|
||||
fs.writeFileSync(keyPath, '', { mode: 0o600 });
|
||||
|
||||
expect(() => new VaultStore(dir)).toThrowError(
|
||||
/Vault key file is corrupted — expected 32 bytes, got 0/
|
||||
);
|
||||
});
|
||||
|
||||
it('Windows key protection — icacls is attempted on win32', () => {
|
||||
const dir = makeTempDir();
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
|
||||
// Ensure no key file exists so ensureKey() will generate one
|
||||
if (fs.existsSync(keyPath)) fs.unlinkSync(keyPath);
|
||||
|
||||
// Mock process.platform to 'win32'
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
// Use the hoisted mock: first call = whoami, second call = icacls
|
||||
mockExecFileSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'whoami') return 'DOMAIN\\testuser';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
try {
|
||||
new VaultStore(dir);
|
||||
|
||||
// Verify whoami was called first, then icacls with the resolved user
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith('whoami', expect.objectContaining({ encoding: 'utf-8' }));
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'icacls',
|
||||
expect.arrayContaining([keyPath, '/inheritance:r', '/grant:r']),
|
||||
expect.objectContaining({ stdio: 'ignore' })
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
mockExecFileSync.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
it('Windows key protection — icacls failure does not prevent vault creation', () => {
|
||||
const dir = makeTempDir();
|
||||
const keyPath = path.join(dir, '.vault-key');
|
||||
if (fs.existsSync(keyPath)) fs.unlinkSync(keyPath);
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
// Mock: whoami succeeds but icacls throws
|
||||
mockExecFileSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'whoami') return 'DOMAIN\\testuser';
|
||||
throw new Error('icacls not found');
|
||||
});
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const vault = new VaultStore(dir);
|
||||
// Vault should still work despite icacls failure
|
||||
vault.set('test', 'value');
|
||||
expect(vault.get('test')!.value).toBe('value');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
mockExecFileSync.mockReset();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user