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); }); });