Files
waggle-os/packages/server/tests/local/chat-approval-timeout.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

149 lines
5.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CronStore, MindDB } from '@waggle/core';
import { resolveApprovalTimeoutPolicy, waitForApprovalDecision } from '../../src/local/routes/chat.js';
describe('chat approval timeout policy', () => {
let tmpDir: string;
let db: MindDB;
let cronStore: CronStore;
let pendingApprovals: Map<string, {
resolve: (approved: boolean) => void;
toolName: string;
input: Record<string, unknown>;
timestamp: number;
}>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T10:00:00.000Z'));
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-approval-timeout-'));
db = new MindDB(path.join(tmpDir, 'test.mind'));
cronStore = new CronStore(db);
pendingApprovals = new Map();
});
afterEach(() => {
vi.useRealTimers();
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function wait(policy = resolveApprovalTimeoutPolicy({}), onSseEvent?: () => void, signal?: AbortSignal) {
const sseEvents: Array<{ event: string; data: Record<string, unknown> }> = [];
const decision = waitForApprovalDecision({
pendingApprovals,
cronStore,
requestId: 'approval-1',
toolName: 'bash',
input: { command: 'rm -rf /' },
heldAction: {
workspaceId: 'workspace-1',
toolName: 'bash',
argsJson: JSON.stringify({ command: 'rm -rf /' }),
summary: 'Run a critical shell command',
riskLevel: 'critical',
approvalClass: 'critical',
},
heldEvent: {
requestId: 'approval-1',
toolName: 'bash',
input: { command: 'rm -rf /' },
held: true,
message: 'Moved to Approvals inbox',
},
policy,
signal,
sendEvent: (event, data) => {
sseEvents.push({ event, data });
onSseEvent?.();
},
onHeld: () => undefined,
});
return { decision, sseEvents };
}
it('defaults to a 300 second deny timeout', async () => {
const policy = resolveApprovalTimeoutPolicy({});
expect(policy).toEqual({ timeoutMs: 300_000, action: 'deny' });
const { decision, sseEvents } = wait(policy);
expect(pendingApprovals.has('approval-1')).toBe(true);
await vi.advanceTimersByTimeAsync(299_999);
expect(pendingApprovals.has('approval-1')).toBe(true);
await vi.advanceTimersByTimeAsync(1);
await expect(decision).resolves.toEqual({ approved: false, held: false, timedOut: true });
expect(pendingApprovals.has('approval-1')).toBe(false);
expect(cronStore.getPendingAction('approval-1')).toBeUndefined();
expect(sseEvents).toEqual([]);
});
it('respects timeout and hold env overrides, persists for 24 hours, and emits before resolving false', async () => {
const policy = resolveApprovalTimeoutPolicy({
WAGGLE_APPROVAL_TIMEOUT_MS: '45000',
WAGGLE_APPROVAL_TIMEOUT_ACTION: 'hold',
});
expect(policy).toEqual({ timeoutMs: 45_000, action: 'hold' });
const order: string[] = [];
const { decision, sseEvents } = wait(policy, () => order.push('approval_held'));
void decision.then(() => order.push('resolved'));
await vi.advanceTimersByTimeAsync(44_999);
expect(cronStore.getPendingAction('approval-1')).toBeUndefined();
await vi.advanceTimersByTimeAsync(1);
await expect(decision).resolves.toEqual({ approved: false, held: true, timedOut: true });
expect(order).toEqual(['approval_held', 'resolved']);
expect(sseEvents).toEqual([{
event: 'approval_held',
data: {
requestId: 'approval-1',
toolName: 'bash',
input: { command: 'rm -rf /' },
held: true,
message: 'Moved to Approvals inbox',
expiresAt: '2026-07-16T10:00:45.000Z',
},
}]);
expect(cronStore.getPendingAction('approval-1')).toMatchObject({
id: 'approval-1',
workspace_id: 'workspace-1',
source: 'approval-timeout:approval-1',
tool_name: 'bash',
args_json: JSON.stringify({ command: 'rm -rf /' }),
summary: 'Run a critical shell command',
risk_level: 'critical',
approval_class: 'critical',
status: 'held',
expires_at: '2026-07-16T10:00:45.000Z',
});
// A critical timeout is only queued here; the existing held-action executor
// remains the post-approval re-validation gate and is never invoked by timeout.
});
it('falls back to the safe defaults for invalid env values', () => {
expect(resolveApprovalTimeoutPolicy({
WAGGLE_APPROVAL_TIMEOUT_MS: 'not-a-number',
WAGGLE_APPROVAL_TIMEOUT_ACTION: 'execute',
})).toEqual({ timeoutMs: 300_000, action: 'deny' });
});
it('settles immediately and removes the pending approval when chat is aborted', async () => {
const controller = new AbortController();
const { decision, sseEvents } = wait(resolveApprovalTimeoutPolicy({}), undefined, controller.signal);
expect(pendingApprovals.has('approval-1')).toBe(true);
controller.abort();
await expect(decision).resolves.toEqual({ approved: false, held: false, timedOut: false });
expect(pendingApprovals.has('approval-1')).toBe(false);
expect(cronStore.getPendingAction('approval-1')).toBeUndefined();
expect(sseEvents).toEqual([]);
});
});