moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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);
});
});
});

View 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);
});
});
});

View 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']);
});
});
});

View 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');
});
});
});

View 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();
});
});
});

View 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');
});
});

View 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',
});
});
});

View 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'");
});
});

View 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();
});
});

View 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();
});
});

View 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');
});
});

View 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');
});
});
});

View 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();
});
});

View 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();
});
});

View 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');
});
});
});

View 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);
});
});

View 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' });
});
});

View 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);
});
});
});

View 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();
}
});
});