Files
waggle-os/packages/server/tests/local/personas-routes.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

291 lines
9.9 KiB
TypeScript

/**
* Personas REST API Route Tests
*
* Tests the GET /api/personas endpoint:
* - Returns array of personas
* - Each persona has id, name, description, icon
* - Does NOT include systemPrompt (sensitive/large)
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import {
deleteCustomPersona,
deployPersonaOverride,
isValidCustomPersonaId,
listPersonas,
rollbackPersonaOverride,
saveCustomPersona,
type AgentPersona,
} from '@waggle/agent';
import { personaRoutes } from '../../src/local/routes/personas.js';
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-personas-route-'));
tempDirs.push(dir);
return dir;
}
function createTestServer(dataDir = os.tmpdir()) {
const server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir } as never);
server.register(personaRoutes);
return server;
}
function makePersona(id: string): AgentPersona {
return {
id,
name: 'Test Persona',
description: 'A test persona',
icon: 'test',
systemPrompt: 'Be useful.',
modelPreference: 'claude-sonnet-4-6',
tools: [],
workspaceAffinity: [],
suggestedCommands: [],
defaultWorkflow: null,
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* Windows handle lag */ }
}
});
describe('Personas Routes', () => {
// ── GET /api/personas ─────────────────────────────────────────────
describe('GET /api/personas', () => {
it('returns an array of personas', async () => {
const server = createTestServer();
const res = await server.inject({ method: 'GET', url: '/api/personas' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.personas).toBeDefined();
expect(Array.isArray(body.personas)).toBe(true);
expect(body.personas.length).toBeGreaterThan(0);
await server.close();
});
it('each persona has id, name, description, icon', async () => {
const server = createTestServer();
const res = await server.inject({ method: 'GET', url: '/api/personas' });
const { personas } = res.json();
for (const persona of personas) {
expect(typeof persona.id).toBe('string');
expect(persona.id.length).toBeGreaterThan(0);
expect(typeof persona.name).toBe('string');
expect(persona.name.length).toBeGreaterThan(0);
expect(typeof persona.description).toBe('string');
expect(persona.description.length).toBeGreaterThan(0);
expect(typeof persona.icon).toBe('string');
expect(persona.icon.length).toBeGreaterThan(0);
}
await server.close();
});
it('includes workspaceAffinity and suggestedCommands', async () => {
const server = createTestServer();
const res = await server.inject({ method: 'GET', url: '/api/personas' });
const { personas } = res.json();
for (const persona of personas) {
expect(Array.isArray(persona.workspaceAffinity)).toBe(true);
expect(Array.isArray(persona.suggestedCommands)).toBe(true);
}
await server.close();
});
it('does NOT include systemPrompt or other sensitive fields', async () => {
const server = createTestServer();
const res = await server.inject({ method: 'GET', url: '/api/personas' });
const { personas } = res.json();
for (const persona of personas) {
expect(persona.systemPrompt).toBeUndefined();
expect(persona.modelPreference).toBeUndefined();
expect(persona.tools).toBeUndefined();
expect(persona.defaultWorkflow).toBeUndefined();
}
await server.close();
});
it('contains all 23 personas', async () => {
const server = createTestServer();
const res = await server.inject({ method: 'GET', url: '/api/personas' });
const { personas } = res.json();
// 22 tiered personas + session-reviewer (internal self-evolution reviewer;
// returned by the raw list but excluded from onboarding + PersonaSwitcher).
expect(personas.length).toBe(23);
const ids = personas.map((p: { id: string }) => p.id);
expect(ids).toContain('researcher');
expect(ids).toContain('writer');
expect(ids).toContain('analyst');
expect(ids).toContain('coder');
expect(ids).toContain('project-manager');
expect(ids).toContain('executive-assistant');
expect(ids).toContain('sales-rep');
expect(ids).toContain('marketer');
expect(ids).toContain('general-purpose');
expect(ids).toContain('planner');
expect(ids).toContain('verifier');
expect(ids).toContain('coordinator');
await server.close();
});
});
describe('custom persona ID boundaries', () => {
it('accepts every shipped persona ID', () => {
for (const persona of listPersonas()) {
expect(isValidCustomPersonaId(persona.id), persona.id).toBe(true);
}
});
it('rejects unsafe explicit IDs before persistence', async () => {
const dataDir = makeTempDir();
const server = createTestServer(dataDir);
const unsafeIds = [
'../outside',
'..\\outside',
'folder/agent',
'folder\\agent',
'.',
'..',
'profile:secret',
'profile::$DATA',
'control\u0000id',
'control\u001fid',
'CON',
'prn.json',
'COM1',
'lPt9.txt',
'COM¹',
'lpt².txt',
'trailing.',
'trailing ',
];
try {
for (const id of unsafeIds) {
const res = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id, name: 'Unsafe Persona', systemPrompt: 'Do unsafe work.' },
});
expect(res.statusCode, id).toBe(400);
expect(res.json()).toMatchObject({ error: 'Invalid request body' });
}
} finally {
await server.close();
}
});
it('keeps portable custom IDs and built-in conflict responses working', async () => {
const dataDir = makeTempDir();
const server = createTestServer(dataDir);
try {
const created = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id: 'qa.v2_agent-1', name: 'QA Agent', systemPrompt: 'Verify changes.' },
});
expect(created.statusCode).toBe(201);
expect(fs.existsSync(path.join(dataDir, 'personas', 'qa.v2_agent-1.json'))).toBe(true);
const conflict = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id: 'coder', name: 'Coder Clone', systemPrompt: 'Write code.' },
});
expect(conflict.statusCode).toBe(409);
expect(conflict.json()).toEqual({ error: 'A built-in persona with this ID already exists' });
} finally {
await server.close();
}
});
it('rejects unsafe IDs on PATCH and DELETE route parameters', async () => {
const server = createTestServer(makeTempDir());
try {
const patched = await server.inject({
method: 'PATCH',
url: '/api/personas/CON',
payload: { name: 'Changed' },
});
expect(patched.statusCode).toBe(400);
const deleted = await server.inject({ method: 'DELETE', url: '/api/personas/CON' });
expect(deleted.statusCode).toBe(400);
} finally {
await server.close();
}
});
it('saveCustomPersona rejects traversal before writing outside personas', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'outside.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => saveCustomPersona(dataDir, makePersona('../outside')))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
it('deleteCustomPersona rejects traversal before deleting outside personas', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'outside.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => deleteCustomPersona(dataDir, '../outside'))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
it('deployPersonaOverride rejects traversal before writing an override', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'evolved.json');
expect(() => deployPersonaOverride(dataDir, {
personaId: '../evolved',
systemPrompt: 'Escaped override',
})).toThrow('Invalid custom persona ID');
expect(fs.existsSync(outsidePath)).toBe(false);
});
it('deployPersonaOverride keeps its validated ID authoritative over overrides', () => {
const dataDir = makeTempDir();
const result = deployPersonaOverride(dataDir, {
personaId: 'safe-persona',
systemPrompt: 'Validated prompt',
overrides: { id: '../evolved', systemPrompt: 'Spoofed prompt' } as never,
});
const deployed = JSON.parse(fs.readFileSync(result.path, 'utf-8')) as AgentPersona;
expect(deployed.id).toBe('safe-persona');
expect(deployed.systemPrompt).toBe('Validated prompt');
expect(fs.existsSync(path.join(dataDir, 'evolved.json'))).toBe(false);
});
it('rollbackPersonaOverride rejects traversal before deleting an override', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'evolved.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => rollbackPersonaOverride(dataDir, '../evolved'))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
});
});