Files
Void-Homelab/tests/api/pending_extended_actions.test.js
root 80363d3e68 feat(ui): Settings view + per-space project cards (status/research/edit/delete) + theming pass
- Settings (#/settings): API tokens (mint/list/revoke), Agents list, Orthos Mode placeholder
- Per-space Projects: Void-1-style expandable cards — inline status, ↻ Research (Eithan stub),
  Edit/New modal, Delete-with-confirm; migration 019 adds research_status/notes/timestamps;
  POST /api/projects/:id/research stub; GET /api/agent-tokens list
- Global +1 font bump; themed scrollbars; larger/bolder themed topbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:06:08 +10:00

115 lines
5.4 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 refsRepo from '../../lib/db/repos/refs.js';
import * as pendingChanges from '../../lib/db/repos/pending_changes.js';
import { applyPendingChange } from '../../lib/api/routes/pending_changes.js';
// Regression coverage for docs/security-followups.md:
// the pending_changes.action CHECK previously rejected the extended actions
// 'upsert' / 'add_dependency' / 'remove_dependency', so a suggest-tier agent
// hitting those routes 500'd on the INSERT. Fix: 'upsert' is a legitimate
// suggestion path (widen CHECK + add an approval dispatch arm); dependency
// mutations are infra wiring and become owner-only.
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}` } };
}
async function makeResource(spaceId, slug) {
const res = await request(app).post(`/api/spaces/${spaceId}/resources`).set(ownerHeaders)
.send({ slug, name: slug, runtime_type: 'lxc' });
return res.body;
}
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('ref upsert as a suggest-tier draft', () => {
it('suggest-tier upsert diverts to a pending_change with action=upsert (not 500)', async () => {
const { agent, headers } = await mintAgent(`sug-up-${Date.now()}`, { read: true, suggest: true });
const res = await request(app).post('/api/refs/upsert').set(headers).send({
space_id: space.id, kind: 'url', source_url: 'https://x', title: 'X',
source_kind: 'karakeep', external_id: 'ext-1'
});
expect(res.status).toBe(202);
expect(res.body.pending).toBe(true);
const change = await pendingChanges.getById(res.body.change_id);
expect(change.agent_id).toBe(agent.id);
expect(change.entity_type).toBe('ref');
expect(change.action).toBe('upsert');
expect(change.payload.external_id).toBe('ext-1');
});
it('approving an upsert pending_change creates the ref via upsertByExternal', async () => {
const { headers } = await mintAgent(`sug-up2-${Date.now()}`, { read: true, suggest: true });
const draft = await request(app).post('/api/refs/upsert').set(headers).send({
space_id: space.id, kind: 'url', source_url: 'https://y', title: 'Y',
source_kind: 'karakeep', external_id: 'ext-2'
});
expect(draft.status).toBe(202);
const approve = await request(app)
.post(`/api/pending-changes/${draft.body.change_id}/approve`).set(ownerHeaders);
expect(approve.status).toBe(200);
const list = await refsRepo.list({ space_id: space.id, kind: 'url', limit: 50, offset: 0 });
const created = list.find(r => r.external_id === 'ext-2');
expect(created).toBeTruthy();
expect(created.title).toBe('Y');
});
it('approving an upsert for an entity whose repo cannot upsert fails clean (ValidationError, not TypeError)', async () => {
// 'upsert' only legitimately originates from refs; guard the dispatch so a
// stray upsert row on another entity_type fails loud, not with a bare TypeError.
await expect(
applyPendingChange({ entity_type: 'task', action: 'upsert', payload: {} }, owner)
).rejects.toThrow(/does not support upsert/i);
});
});
describe('resource dependency mutations are owner-only', () => {
it('owner can add and remove a dependency', async () => {
const a = await makeResource(space.id, 'dep-a');
const b = await makeResource(space.id, 'dep-b');
const add = await request(app).post(`/api/resources/${a.id}/dependencies`).set(ownerHeaders)
.send({ depends_on: b.id });
expect(add.status).toBe(201);
const del = await request(app).delete(`/api/resources/${a.id}/dependencies/${b.id}`).set(ownerHeaders);
expect(del.status).toBe(204);
});
it('an agent (even suggest tier) cannot add a dependency → 403, no pending_change', async () => {
const a = await makeResource(space.id, 'dep-c');
const b = await makeResource(space.id, 'dep-d');
const { headers } = await mintAgent(`sug-dep-${Date.now()}`, { read: true, suggest: true });
const res = await request(app).post(`/api/resources/${a.id}/dependencies`).set(headers)
.send({ depends_on: b.id });
expect(res.status).toBe(403);
const pending = await pendingChanges.listPending({ limit: 50 });
expect(pending.some(p => p.action === 'add_dependency')).toBe(false);
});
it('an agent cannot remove a dependency → 403', async () => {
const a = await makeResource(space.id, 'dep-e');
const b = await makeResource(space.id, 'dep-f');
await request(app).post(`/api/resources/${a.id}/dependencies`).set(ownerHeaders)
.send({ depends_on: b.id });
const { headers } = await mintAgent(`sug-dep2-${Date.now()}`, { read: true, suggest: true });
const res = await request(app).delete(`/api/resources/${a.id}/dependencies/${b.id}`).set(headers);
expect(res.status).toBe(403);
});
});