Files
Void-Homelab/tests/api/capability_routes.test.js
root 56805053f0 feat(api): capability enforcement on writes
Add lib/api/cap.js: requireWrite(entity_type) maps HTTP method to
action, runs canAct, and tags req.capTier as allow|suggest|deny→403.
Mutating routes (pages, projects, tasks, refs, resources, source_docs)
now check req.capTier and either run the repo (allow) or divert to
pending_changes returning 202 (suggest). Owner and worker actors stay
on the allow path. requireOwner helper added for Task 11.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:03:52 +10:00

66 lines
2.6 KiB
JavaScript

import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import request from 'supertest';
import { setup } from './helpers.js';
import * as spacesRepo from '../../lib/db/repos/spaces.js';
import * as agentsRepo from '../../lib/db/repos/agents.js';
import * as pendingChanges from '../../lib/db/repos/pending_changes.js';
let app, ownerHeaders, space;
const owner = { kind: 'user', id: null };
async function mintAgent(slug, caps, scopes = {}) {
const a = await agentsRepo.create({
slug, name: slug, kind: 'claude', model: 'sonnet',
capabilities: caps, scopes
}, owner);
const { token } = await agentsRepo.createToken(a.id, 'test');
return { agent: a, headers: { Authorization: `Bearer ${token}` } };
}
beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
beforeEach(async () => {
space = await spacesRepo.create({ slug: `s-${Date.now()}-${Math.random().toString(36).slice(2,5)}`, name: 'S' }, owner);
});
describe('capability enforcement on writes', () => {
it('agent at allow tier writes through (201)', async () => {
const { headers } = await mintAgent(`allow-${Date.now()}`,
{ read: true, write: true }, { page: true });
const res = await request(app)
.post(`/api/spaces/${space.id}/pages`).set(headers)
.send({ slug: 'a', title: 'A', body_md: 'hi' });
expect(res.status).toBe(201);
});
it('agent at suggest tier diverts to pending_changes (202)', async () => {
const { agent, headers } = await mintAgent(`sug-${Date.now()}`,
{ read: true, suggest: true });
const res = await request(app)
.post(`/api/spaces/${space.id}/pages`).set(headers)
.send({ slug: 'p', title: 'P', body_md: 'draft' });
expect(res.status).toBe(202);
expect(res.body.pending).toBe(true);
expect(res.body.change_id).toBeDefined();
const change = await pendingChanges.getById(res.body.change_id);
expect(change.agent_id).toBe(agent.id);
expect(change.entity_type).toBe('page');
expect(change.action).toBe('create');
expect(change.payload.title).toBe('P');
});
it('agent at deny tier → 403', async () => {
const { headers } = await mintAgent(`deny-${Date.now()}`, { read: true });
const res = await request(app)
.post(`/api/spaces/${space.id}/pages`).set(headers)
.send({ slug: 'p', title: 'P' });
expect(res.status).toBe(403);
});
it('owner still writes through', async () => {
const res = await request(app)
.post(`/api/spaces/${space.id}/pages`).set(ownerHeaders)
.send({ slug: 'owner', title: 'Owner' });
expect(res.status).toBe(201);
});
});