godcrm/scripts/smoke-adr0030-p5.mjs
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

421 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Smoke test for ADR-0030 Phase 5 — TOTP approval gate.
*
* Runs against the LOCAL godcrm_test DB (per ADR-0009 isolation rules).
* Does NOT hit PROD or DEV. Does NOT spawn the real `claude --print`
* binary — case 10 uses a stub via RUN_CLAUDE_SCRIPT_OVERRIDE.
*
* Cases (10 total):
* 1. generateApprovalCode — shape + hash matches.
* 2. timingSafeHashEqual — equal hashes pass; mismatched / malformed fail.
* 3. persistApprovalRequest → readApprovalState round-trip + run_state flip.
* 4. resolveApproval('approved') → run_state='preparing', resolved_at set.
* 5. resolveApproval('denied') → run_state='failed', terminal='approval_denied'.
* 6. resolveApproval('expired') → run_state='failed', terminal='approval_timeout'.
* 7. recordAttempt — 5 wrong attempts auto-deny (state='denied').
* 8. awaitApproval — already-approved short-circuits immediately.
* 9. awaitApproval — TTL elapsed → outcome='expired', state persisted.
* 10. End-to-end: dispatcher tick with RUN_REQUIRE_APPROVAL='true' and stub
* claude → ticket reaches 'awaiting_approval'; in-flight resolveApproval
* unblocks the gate; final run_state='succeeded' + audit log carries
* 'approval_required' and 'approval_granted' entries.
*/
// ─── Force test DB + live phase BEFORE any module import ───────────
process.env.POSTGRES_HOST = 'localhost';
process.env.POSTGRES_DB = 'godcrm_test';
process.env.POSTGRES_USER = 'godcrm';
process.env.POSTGRES_PASSWORD = 'godcrm_dev_2026';
process.env.POSTGRES_PORT = '5432';
process.env.AGENT_RUN_DISPATCHER_ENABLED = 'false'; // we drive ticks manually
process.env.RUN_DISPATCHER_PHASE = 'live';
process.env.RUN_REQUIRE_APPROVAL = 'true';
process.env.NODE_ENV = 'test';
delete process.env.BUSINESS_CRM_IS_PROD;
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const approval = await import(
'../backend/services/agent-run-dispatcher/approval-gate.js'
);
const {
generateApprovalCode,
hashCode,
timingSafeHashEqual,
persistApprovalRequest,
readApprovalState,
recordAttempt,
resolveApproval,
awaitApproval,
APPROVAL_OUTCOMES,
} = approval;
const { runTick } = await import(
'../backend/services/agent-run-dispatcher/index.js'
);
const { destroyWorkspace } = await import(
'../backend/services/agent-run-dispatcher/workspace-manager.js'
);
const { dbGet, dbRun } = await import('../backend/database/connection.js');
const TICKETS_TABLE_ID = 1708;
const AGENTS_TABLE_ID = 1784;
const STATE_BACKLOG = 24275;
const SMOKE_TAG = 'smoke-adr0030-p5';
let pass = 0;
let fail = 0;
const insertedRowIds = []; // { table_id, id }
const stubFiles = [];
const e2eTicketIds = []; // tickets that may have created workspaces
function assert(label, cond, extra = '') {
if (cond) { console.log(` PASS ${label}`); pass++; }
else { console.log(` FAIL ${label} ${extra}`); fail++; }
}
function genBaseId() {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let s = '';
for (let i = 0; i < 8; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)];
return s;
}
async function insertRow(tableId, data) {
const row = await dbGet(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES ($1, $2, $3::jsonb, NOW(), NOW())
RETURNING id`,
[tableId, genBaseId(), JSON.stringify(data)]
);
insertedRowIds.push({ table_id: tableId, id: row.id });
return row.id;
}
async function getTicketData(ticketId) {
const row = await dbGet(
`SELECT data FROM table_rows WHERE table_id = $1 AND id = $2`,
[TICKETS_TABLE_ID, ticketId]
);
if (!row?.data) return null;
return typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
}
async function makeTicketAwaitingApproval({ overrideExpires = null } = {}) {
const agentId = await insertRow(AGENTS_TABLE_ID, {
name: 'Smoke Agent P5',
system_prompt: 'Be brief.',
smoke_tag: SMOKE_TAG,
});
const ticketId = await insertRow(TICKETS_TABLE_ID, {
state: STATE_BACKLOG,
assigned_to: String(agentId),
title: 'P5 smoke ticket',
what: 'P5 smoke ticket',
smoke_tag: SMOKE_TAG,
run_state: 'preparing',
run_attempt: 1,
});
const { code, code_hash, expires_at, generated_at } = generateApprovalCode();
const finalExpires = overrideExpires || expires_at;
await persistApprovalRequest(ticketId, {
code_hash,
expires_at: finalExpires,
generated_at,
});
return { agentId, ticketId, code, code_hash, expires_at: finalExpires };
}
async function writeStubScript(name, body) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'p5-stub-'));
const file = path.join(dir, name);
await fs.writeFile(file, body, { mode: 0o755 });
await fs.chmod(file, 0o755);
stubFiles.push(file);
return file;
}
async function cleanup() {
delete process.env.RUN_CLAUDE_SCRIPT_OVERRIDE;
// Clean any workspaces we may have created.
for (const id of e2eTicketIds) {
try { await destroyWorkspace(id); } catch { /* best-effort */ }
}
if (insertedRowIds.length > 0) {
const ids = insertedRowIds.map((r) => r.id);
try {
await dbRun(`DELETE FROM table_rows WHERE id = ANY($1::int[])`, [ids]);
} catch { /* best-effort */ }
}
// Catch orphans by smoke_tag.
await dbRun(
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
[SMOKE_TAG]
).catch(() => {});
for (const f of stubFiles) {
try {
await fs.rm(path.dirname(f), { recursive: true, force: true });
} catch { /* best-effort */ }
}
}
async function main() {
console.log('ADR-0030 Phase 5 smoke test (godcrm_test) — start');
console.log(` RUN_REQUIRE_APPROVAL=${process.env.RUN_REQUIRE_APPROVAL}`);
console.log(` RUN_DISPATCHER_PHASE=${process.env.RUN_DISPATCHER_PHASE}`);
// Pre-flight scrub.
await dbRun(
`DELETE FROM table_rows WHERE data->>'smoke_tag' = $1`,
[SMOKE_TAG]
);
// ── Case 1: generateApprovalCode shape
console.log('\nCase 1: generateApprovalCode shape + hash');
{
const a = generateApprovalCode();
assert('code is 6 digits', /^\d{6}$/.test(a.code), `got ${a.code}`);
assert('code_hash is 64 hex chars', /^[0-9a-f]{64}$/.test(a.code_hash));
assert('hash matches code', a.code_hash === hashCode(a.code));
assert('expires_at parseable', !Number.isNaN(Date.parse(a.expires_at)));
assert('generated_at parseable', !Number.isNaN(Date.parse(a.generated_at)));
const ttlMs = Date.parse(a.expires_at) - Date.parse(a.generated_at);
assert('ttl ~10 min', ttlMs > 9 * 60_000 && ttlMs < 11 * 60_000, `ttlMs=${ttlMs}`);
// Sanity: two consecutive codes should almost always differ.
const b = generateApprovalCode();
assert('two consecutive codes differ', a.code !== b.code);
}
// ── Case 2: timingSafeHashEqual edge cases
console.log('\nCase 2: timingSafeHashEqual edge cases');
{
const h = hashCode('123456');
assert('equal hashes match', timingSafeHashEqual(h, h));
assert('different hashes mismatch', !timingSafeHashEqual(h, hashCode('654321')));
assert('non-hex returns false', !timingSafeHashEqual(h, 'z'.repeat(64)));
assert('short string returns false', !timingSafeHashEqual(h, 'abc'));
assert('null returns false', !timingSafeHashEqual(h, null));
assert('non-string returns false', !timingSafeHashEqual(h, 12345));
}
// ── Case 3: persistApprovalRequest → readApprovalState round-trip
console.log('\nCase 3: persist + read round-trip + run_state flip');
{
const { ticketId, code_hash, expires_at } = await makeTicketAwaitingApproval();
const view = await readApprovalState(ticketId);
assert('view present', view.present);
assert('view.state === pending', view.state === 'pending');
assert('view.attempts === 0', view.attempts === 0);
assert('view.code_hash matches', view.code_hash === code_hash);
assert('view.expires_at matches', view.expires_at === expires_at);
assert('view.run_state === awaiting_approval',
view.run_state === 'awaiting_approval', `got ${view.run_state}`);
assert('view.expired === false', view.expired === false);
const data = await getTicketData(ticketId);
assert('ticket.run_state === awaiting_approval',
data.run_state === 'awaiting_approval', `got ${data?.run_state}`);
assert('ticket.run_approval object present', !!data.run_approval);
assert('ticket.run_approval.code_hash redacted from logs (still on row)',
data.run_approval.code_hash === code_hash);
}
// ── Case 4: resolveApproval('approved') → run_state='preparing'
console.log('\nCase 4: resolveApproval(approved)');
{
const { ticketId } = await makeTicketAwaitingApproval();
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
const data = await getTicketData(ticketId);
assert('run_state === preparing', data.run_state === 'preparing', `got ${data?.run_state}`);
assert('run_approval.state === approved', data.run_approval?.state === 'approved');
assert('run_approval.resolved_at set', !!data.run_approval?.resolved_at);
assert('run_approval.resolved_by === 1', data.run_approval?.resolved_by === 1);
assert('run_terminal_reason NOT set', !data.run_terminal_reason);
}
// ── Case 5: resolveApproval('denied') → run_state='failed', terminal='approval_denied'
console.log('\nCase 5: resolveApproval(denied)');
{
const { ticketId } = await makeTicketAwaitingApproval();
await resolveApproval(ticketId, { state: 'denied', resolved_by: 1, reason: 'explicit_deny' });
const data = await getTicketData(ticketId);
assert('run_state === failed', data.run_state === 'failed', `got ${data?.run_state}`);
assert('run_terminal_reason === approval_denied',
data.run_terminal_reason === 'approval_denied',
`got ${data?.run_terminal_reason}`);
assert('run_finished_at set', !!data.run_finished_at);
assert('run_approval.state === denied', data.run_approval?.state === 'denied');
}
// ── Case 6: resolveApproval('expired') → run_state='failed', terminal='approval_timeout'
console.log('\nCase 6: resolveApproval(expired)');
{
const { ticketId } = await makeTicketAwaitingApproval();
await resolveApproval(ticketId, { state: 'expired', reason: 'ttl_elapsed' });
const data = await getTicketData(ticketId);
assert('run_state === failed', data.run_state === 'failed', `got ${data?.run_state}`);
assert('run_terminal_reason === approval_timeout',
data.run_terminal_reason === 'approval_timeout',
`got ${data?.run_terminal_reason}`);
assert('run_approval.state === expired', data.run_approval?.state === 'expired');
}
// ── Case 7: recordAttempt — 5 wrong attempts auto-deny
console.log('\nCase 7: recordAttempt 5x wrong → auto-deny');
{
const { ticketId } = await makeTicketAwaitingApproval();
let last;
for (let i = 1; i <= 5; i++) {
last = await recordAttempt(ticketId, false);
}
assert('attempts === 5', last.attempts === 5, JSON.stringify(last));
assert('state === denied (auto)', last.state === 'denied', JSON.stringify(last));
assert('attempts_remaining === 0', last.attempts_remaining === 0);
const data = await getTicketData(ticketId);
assert('run_state flipped to failed after auto-deny',
data.run_state === 'failed', `got ${data?.run_state}`);
assert('terminal_reason === approval_denied (auto)',
data.run_terminal_reason === 'approval_denied');
}
// ── Case 8: awaitApproval — already-approved short-circuit
console.log('\nCase 8: awaitApproval short-circuits on already-approved');
{
const { ticketId } = await makeTicketAwaitingApproval();
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
const t0 = Date.now();
const result = await awaitApproval(ticketId, { pollMs: 500, timeoutMs: 30_000 });
const elapsed = Date.now() - t0;
assert('outcome === approved', result.outcome === APPROVAL_OUTCOMES.APPROVED);
assert('returns within 1s (no poll wait)', elapsed < 1000, `elapsed=${elapsed}ms`);
}
// ── Case 9: awaitApproval — TTL elapsed → expired, state persisted
console.log('\nCase 9: awaitApproval TTL elapsed → expired');
{
const expiredAt = new Date(Date.now() - 60_000).toISOString();
const { ticketId } = await makeTicketAwaitingApproval({ overrideExpires: expiredAt });
const t0 = Date.now();
const result = await awaitApproval(ticketId, { pollMs: 500, timeoutMs: 30_000 });
const elapsed = Date.now() - t0;
assert('outcome === expired', result.outcome === APPROVAL_OUTCOMES.EXPIRED, JSON.stringify(result));
assert('returns fast (TTL already past)', elapsed < 1500, `elapsed=${elapsed}ms`);
const data = await getTicketData(ticketId);
assert('run_approval.state persisted as expired',
data.run_approval?.state === 'expired', `got ${data?.run_approval?.state}`);
assert('run_state flipped to failed',
data.run_state === 'failed', `got ${data?.run_state}`);
assert('terminal_reason === approval_timeout',
data.run_terminal_reason === 'approval_timeout');
}
// ── Case 10: end-to-end dispatcher tick with stub claude + in-flight approve
console.log('\nCase 10: end-to-end tick — gate fires, in-flight approve, run completes');
{
const stub = await writeStubScript('stub-p5.sh', `#!/usr/bin/env bash
PROMPT=$(cat)
echo '{"type":"info","message":"p5_stub_started"}'
echo '{"type":"output","content":"P5 stub output content.","status":"success","exit":0}'
echo '{"type":"result","status":"success","exit":0}'
exit 0
`);
process.env.RUN_CLAUDE_SCRIPT_OVERRIDE = stub;
const agentId = await insertRow(AGENTS_TABLE_ID, {
name: 'Smoke Agent P5 E2E',
system_prompt: 'Be brief.',
smoke_tag: SMOKE_TAG,
});
const ticketId = await insertRow(TICKETS_TABLE_ID, {
state: STATE_BACKLOG,
assigned_to: String(agentId),
title: 'P5 e2e smoke ticket',
what: 'P5 e2e smoke ticket',
smoke_tag: SMOKE_TAG,
run_state: 'idle',
});
e2eTicketIds.push(ticketId);
// Kick the tick — runs in background; we'll resolve the gate from outside.
const tickPromise = runTick({ source: 'smoke_p5_case_10' });
// Poll until ticket reaches awaiting_approval (gate persisted).
let gateSeen = false;
const pollDeadline = Date.now() + 30_000;
while (Date.now() < pollDeadline) {
const view = await readApprovalState(ticketId);
if (view.present && view.run_state === 'awaiting_approval') {
gateSeen = true;
break;
}
await new Promise((r) => setTimeout(r, 100));
}
assert('gate fired (run_state hit awaiting_approval)', gateSeen,
'never observed awaiting_approval within 30s');
if (gateSeen) {
// Simulate operator approve (bypassing API since we already trust the
// function — case 4 covers approve mechanics).
await resolveApproval(ticketId, { state: 'approved', resolved_by: 1 });
}
// Now the tick should finish.
const stats = await tickPromise;
assert('tick reports awaiting_approval >= 1',
stats.awaiting_approval >= 1, JSON.stringify(stats));
assert('tick reports approval_approved >= 1',
stats.approval_approved >= 1, JSON.stringify(stats));
assert('tick reports live_succeeded >= 1',
stats.live_succeeded >= 1, JSON.stringify(stats));
const data = await getTicketData(ticketId);
assert('final run_state === succeeded',
data.run_state === 'succeeded', `got ${data?.run_state}`);
assert('run_terminal_reason === completed',
data.run_terminal_reason === 'completed', `got ${data?.run_terminal_reason}`);
const audit = data.run_audit_log || [];
const approvalReq = audit.find((e) => e.reason === 'approval_required');
const approvalGranted = audit.find((e) => e.reason === 'approval_granted');
const completed = audit.find((e) => e.reason === 'completed');
assert('audit contains approval_required entry', !!approvalReq, JSON.stringify(audit));
assert('audit contains approval_granted entry', !!approvalGranted, JSON.stringify(audit));
assert('audit contains completed entry', !!completed, JSON.stringify(audit));
// Spot-check the approval_required entry has expires_at, no plaintext code.
if (approvalReq) {
assert('approval_required carries expires_at', !!approvalReq.expires_at);
assert('approval_required does NOT carry plaintext code',
!('code' in approvalReq) && !approvalReq.code,
JSON.stringify(approvalReq));
}
// Workspace must be cleaned up.
let dirGone = false;
try {
await fs.access(`/root/workspaces/T-${ticketId}`);
} catch {
dirGone = true;
}
assert('workspace dir removed after run', dirGone);
}
console.log(`\n${pass + fail} assertions: ${pass} pass / ${fail} fail`);
if (fail === 0) console.log('all assertions passed');
}
main()
.catch((err) => {
console.error('SMOKE FAILED with exception:', err);
fail++;
})
.finally(async () => {
try { await cleanup(); } catch (err) { console.error('cleanup error:', err); }
process.exit(fail === 0 ? 0 : 1);
});