godcrm/backend/services/agent-permissions/__tests__/reservations.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

168 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ADR-0181 — Agent Write-Reservations. Runs against godcrm_test (ADR-0009).
// The ADR-0009 boot guard (backend/test/setup.js) hard-aborts if this points
// at a combat master, so the DB-backed cases below are safe by construction.
import { describe, test, expect, beforeAll, afterEach } from 'vitest';
import { dbRun } from '../../../database/connection.js';
import {
normalizeReservationPath,
acquireWriteReservation,
releaseWriteReservation,
checkReservation,
gateWrite,
WRITE_TOOLS,
} from '../reservations.js';
// ── Part A — path normalization (pure, no DB) — ADR-0181 AC#4 ──────────────
describe('normalizeReservationPath', () => {
const REL = 'backend/routes/v3/__tests__/sim-setup-tables.test.js';
test('shared edit-tree absolute path → repo-relative key', () => {
expect(normalizeReservationPath(`/root/production/business-crm/${REL}`)).toBe(REL);
});
test('per-agent worktree path → same repo-relative key', () => {
expect(
normalizeReservationPath(`/root/production/business-crm/.claude/worktrees/agent-ralph-7/${REL}`)
).toBe(REL);
});
test('deploy-artifact path → same repo-relative key', () => {
expect(normalizeReservationPath(`/srv/godcrm/live/${REL}`)).toBe(REL);
});
test('all three tree forms collapse to ONE key', () => {
const a = normalizeReservationPath(`/root/production/business-crm/${REL}`);
const b = normalizeReservationPath(`/root/production/business-crm/.claude/worktrees/agent-x/${REL}`);
const c = normalizeReservationPath(`/srv/godcrm/live/${REL}`);
expect(new Set([a, b, c]).size).toBe(1);
});
test('already-relative path passes through', () => {
expect(normalizeReservationPath(REL)).toBe(REL);
});
test('unusable input → null', () => {
expect(normalizeReservationPath(null)).toBeNull();
expect(normalizeReservationPath('')).toBeNull();
expect(normalizeReservationPath(42)).toBeNull();
});
});
// ── Part B — acquire / gate / steal (DB-backed, godcrm_test) ───────────────
describe('write-reservation acquire semantics', () => {
const KEY = `__adr0181_test__/${Date.now()}_a.js`;
beforeAll(async () => {
// Idempotent — mirrors migration 079 so the suite is hermetic on a fresh
// godcrm_test that may not have the migration applied yet.
await dbRun(`
CREATE TABLE IF NOT EXISTS _agent_write_reservations (
file_path TEXT PRIMARY KEY,
wp_id TEXT,
holder_session TEXT NOT NULL,
agent_id INT,
space_id INT,
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
renewed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL
)
`);
});
afterEach(async () => {
await dbRun(`DELETE FROM _agent_write_reservations WHERE file_path LIKE '__adr0181_test__/%'`);
});
test('AC#2 fresh path → granted', async () => {
const r = await acquireWriteReservation({ filePath: KEY, holderSession: 'loopA' });
expect(r.granted).toBe(true);
});
test('AC#2 same holder re-acquire → granted (idempotent renew)', async () => {
await acquireWriteReservation({ filePath: KEY, holderSession: 'loopA', wpId: 'WP-1' });
const r = await acquireWriteReservation({ filePath: KEY, holderSession: 'loopA', wpId: 'WP-1' });
expect(r.granted).toBe(true);
const held = await checkReservation(KEY);
expect(held.holder_session).toBe('loopA');
expect(held.renewed_at).not.toBeNull(); // DO UPDATE path stamped renewed_at
});
test('AC#2 different live holder → denied with holder + expiry', async () => {
await acquireWriteReservation({ filePath: KEY, holderSession: 'loopA', wpId: 'WP-1' });
const r = await acquireWriteReservation({ filePath: KEY, holderSession: 'loopB' });
expect(r.granted).toBe(false);
expect(r.holder).toBe('loopA');
expect(r.wpId).toBe('WP-1');
expect(r.expiresAt).toBeTruthy();
});
test('AC#3 stale reservation is transparently stolen', async () => {
// Acquire with a 1s TTL, then let it expire.
await acquireWriteReservation({ filePath: KEY, holderSession: 'deadLoop', ttlSeconds: 1 });
await new Promise((res) => setTimeout(res, 1100));
const r = await acquireWriteReservation({ filePath: KEY, holderSession: 'loopB' });
expect(r.granted).toBe(true);
const held = await checkReservation(KEY);
expect(held.holder_session).toBe('loopB');
});
test('release removes only the holders own row', async () => {
await acquireWriteReservation({ filePath: KEY, holderSession: 'loopA' });
// Wrong holder cannot release.
expect(await releaseWriteReservation({ filePath: KEY, holderSession: 'loopB' })).toBe(false);
// Rightful holder can (path given in absolute form → normalized).
const abs = `/root/production/business-crm/${KEY}`;
expect(await releaseWriteReservation({ filePath: abs, holderSession: 'loopA' })).toBe(true);
expect(await checkReservation(KEY)).toBeNull();
});
});
// ── Part C — gateWrite wiring (DB-backed) — AC#2 / AC#6 ────────────────────
describe('gateWrite (/check post-allow stage)', () => {
const REL = `__adr0181_test__/${Date.now()}_gate.js`;
const ABS = `/root/production/business-crm/${REL}`;
beforeAll(async () => {
await dbRun(`
CREATE TABLE IF NOT EXISTS _agent_write_reservations (
file_path TEXT PRIMARY KEY, wp_id TEXT, holder_session TEXT NOT NULL,
agent_id INT, space_id INT, acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
renewed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL
)`);
});
afterEach(async () => {
await dbRun(`DELETE FROM _agent_write_reservations WHERE file_path LIKE '__adr0181_test__/%'`);
});
test('AC#6 non-mutation tool → null (untouched)', async () => {
const out = await gateWrite({ toolName: 'Bash', toolInput: { command: 'ls' }, holderSession: 'loopA' });
expect(out).toBeNull();
});
test('Edit with no holder session → null (cannot reserve, allow)', async () => {
const out = await gateWrite({ toolName: 'Edit', toolInput: { file_path: ABS }, holderSession: null });
expect(out).toBeNull();
});
test('AC#2 first Edit → null (allow); rival Edit → soft-deny object', async () => {
const first = await gateWrite({ toolName: 'Edit', toolInput: { file_path: ABS }, holderSession: 'loopA', wpId: 'WP-9' });
expect(first).toBeNull(); // acquired → keep policy allow
// A DIFFERENT loop editing the SAME logical file via a worktree path.
const wt = `/root/production/business-crm/.claude/worktrees/agent-b/${REL}`;
const rival = await gateWrite({ toolName: 'Edit', toolInput: { file_path: wt }, holderSession: 'loopB' });
expect(rival).toMatchObject({ decision: 'deny', matched_source: 'reservation', matched_rule_id: null });
expect(rival.reason).toContain('loopA');
});
test('same holder re-Edit (multi-file WP turn) → null (renew, allow)', async () => {
await gateWrite({ toolName: 'Write', toolInput: { file_path: ABS }, holderSession: 'loopA' });
const again = await gateWrite({ toolName: 'Edit', toolInput: { file_path: ABS }, holderSession: 'loopA' });
expect(again).toBeNull();
});
});
test('WRITE_TOOLS covers file-mutation tools only', () => {
expect([...WRITE_TOOLS].sort()).toEqual(['Edit', 'MultiEdit', 'NotebookEdit', 'Write']);
expect(WRITE_TOOLS.has('Bash')).toBe(false);
});