Files
waggle-os/packages/server/tests/local/p5-skill-governance.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

107 lines
5.2 KiB
TypeScript

/**
* P5/D4 skill-write governance — HTTP route audit + provenance (binding iv).
*
* Asserts the raw POST/PUT/DELETE /api/skills paths now (a) flow through the
* shared skill-write service, so they redact, stamp provenance, and ENTER THE
* AUDIT TRAIL, and (b) GET /api/skills surfaces initiator/source so the Skills
* Hub can badge agent-authored skills.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { skillRoutes } from '../../src/local/routes/skills.js';
interface AuditRow { action: string; initiator: string; capabilityName: string; capabilityType: string }
describe('P5/D4 skill governance routes', () => {
let dataDir: string;
let skillsDir: string;
let server: ReturnType<typeof Fastify>;
let audit: AuditRow[];
beforeEach(async () => {
dataDir = path.join(os.tmpdir(), `waggle-p5-${randomUUID()}`);
skillsDir = path.join(dataDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
audit = [];
server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir });
server.decorate('agentState', { skills: [] });
server.decorate('skillHashStore', { setHash: () => {}, removeHash: () => {}, checkAll: () => ({ changed: [], unchanged: [], missing: [] }) });
server.decorate('auditStore', {
record: (e: AuditRow) => { audit.push(e); return e; },
getRecent: () => audit,
});
await server.register(skillRoutes);
await server.ready();
});
afterEach(async () => { await server.close(); fs.rmSync(dataDir, { recursive: true, force: true }); });
it('POST /api/skills stamps user provenance and audits installed', async () => {
const res = await server.inject({ method: 'POST', url: '/api/skills', payload: { name: 'my-note', content: '# Note\nbody' } });
expect(res.statusCode).toBe(200);
const raw = fs.readFileSync(path.join(skillsDir, 'my-note.md'), 'utf-8');
expect(raw).toContain('initiator: user');
expect(raw).toContain('source: api');
const installed = audit.filter(a => a.action === 'installed' && a.capabilityName === 'my-note');
expect(installed).toHaveLength(1);
expect(installed[0].initiator).toBe('user');
});
it('GET /api/skills returns initiator/source provenance', async () => {
// An agent-authored skill on disk.
fs.writeFileSync(path.join(skillsDir, 'agent-made.md'), '---\ninitiator: agent\nsource: chat\n---\n\n# Agent skill');
// A legacy skill with no frontmatter → 'built-in' (NOT 'user': claiming
// user authorship for bundled content made the provenance badge
// unfalsifiable for pre-P5 skills).
fs.writeFileSync(path.join(skillsDir, 'legacy.md'), '# Legacy skill, no frontmatter');
const res = await server.inject({ method: 'GET', url: '/api/skills' });
const body = res.json() as { skills: Array<{ name: string; initiator: string; source?: string }> };
const agentSkill = body.skills.find(s => s.name === 'agent-made');
const legacy = body.skills.find(s => s.name === 'legacy');
expect(agentSkill?.initiator).toBe('agent');
expect(agentSkill?.source).toBe('chat');
expect(legacy?.initiator).toBe('built-in');
});
it('GET /api/skills preview is the body, not the stamped frontmatter (review #3)', async () => {
fs.writeFileSync(path.join(skillsDir, 'stamped.md'), '---\ninitiator: agent\nsource: chat\n---\n\n# Real Heading\nThe actual skill body.');
const res = await server.inject({ method: 'GET', url: '/api/skills' });
const body = res.json() as { skills: Array<{ name: string; preview?: string }> };
const s = body.skills.find(x => x.name === 'stamped');
expect(s?.preview).not.toContain('initiator:');
expect(s?.preview).not.toContain('---');
expect(s?.preview).toContain('Real Heading');
});
it('DELETE /api/skills/:name audits uninstalled', async () => {
await server.inject({ method: 'POST', url: '/api/skills', payload: { name: 'doomed', content: 'x' } });
audit.length = 0;
const res = await server.inject({ method: 'DELETE', url: '/api/skills/doomed' });
expect(res.statusCode).toBe(200);
expect(fs.existsSync(path.join(skillsDir, 'doomed.md'))).toBe(false);
const uninstalled = audit.filter(a => a.action === 'uninstalled');
expect(uninstalled).toHaveLength(1);
expect(uninstalled[0].initiator).toBe('user');
});
it('PUT /api/skills/:name preserves original provenance (sticky)', async () => {
fs.writeFileSync(path.join(skillsDir, 'shared.md'), '---\ninitiator: agent\nsource: chat\n---\n\nv1');
const res = await server.inject({ method: 'PUT', url: '/api/skills/shared', payload: { content: 'v2 edited in UI' } });
expect(res.statusCode).toBe(200);
const raw = fs.readFileSync(path.join(skillsDir, 'shared.md'), 'utf-8');
expect(raw).toContain('initiator: agent'); // not relaundered to user
expect(raw).toContain('v2 edited in UI');
});
it('POST /api/skills rejects path traversal without auditing', async () => {
const res = await server.inject({ method: 'POST', url: '/api/skills', payload: { name: '../evil', content: 'x' } });
expect(res.statusCode).toBe(400);
expect(audit).toHaveLength(0);
});
});