This commit is contained in:
106
packages/server/tests/local/p5-skill-governance.test.ts
Normal file
106
packages/server/tests/local/p5-skill-governance.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user