Files
Codeman/test/routes/approval-routes.test.ts
T
Codeman maintainer a35ddd82ba feat(codex): raise a screen-read dialog as BLOCKED, alert-only
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.

It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.

The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.

Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:18:37 +02:00

606 lines
24 KiB
TypeScript
Raw 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.
/**
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject(),
* no live port. The hook-event route is registered alongside so items are
* created through the REAL ingestion path (sanitize → notePrompt with the
* terminal-buffer capture fallback), not by poking the store directly.
*
* The routes read the process-wide `approvalInbox` singleton, so every test
* drains it in afterEach; a leaked pending item would bleed into the next test.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
type MockRouteContext = ReturnType<typeof createMockRouteContext>;
interface RouteTestHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* Local harness mirroring production's uniform-envelope preSerialization hook
* (server.ts), so `{success:false}` bodies carry their conventional 4xx status.
* The shared createRouteTestHarness deliberately omits that hook; these routes
* signal every guard through returned error envelopes, so the status IS the
* behavior under test. Pattern copied from hook-event-routes.test.ts.
*/
async function createEnvelopeHarness(authUser?: {
username: string;
role: 'admin' | 'user';
}): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (authUser) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
registerHookEventRoutes(app, ctx as never);
registerApprovalRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const SESSION_ID = 'approval-test-session';
const PERMISSION_DIALOG = [
' Claude needs your permission to use Bash',
' ❯ 1. Yes',
' 2. Yes, and don’t ask again for this command',
' 3. No, and tell Claude what to do differently (esc)',
].join('\n');
async function postHook(harness: RouteTestHarness, event: string, data: Record<string, unknown> = {}): Promise<void> {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: SESSION_ID, data },
});
expect(res.statusCode).toBe(200);
}
async function listApprovals(harness: RouteTestHarness): Promise<Array<Record<string, unknown>>> {
const res = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
expect(res.statusCode).toBe(200);
return res.json().data.approvals;
}
describe('approval routes', () => {
let harness: RouteTestHarness;
let session: MockSession;
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.terminalBuffer = PERMISSION_DIALOG;
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('a permission_prompt hook creates a pending item with parsed options and context', async () => {
await postHook(harness, 'permission_prompt', {
tool_name: 'Bash',
tool_input: { command: 'rm -rf node_modules' },
message: 'Claude needs your permission to use Bash',
cwd: '/tmp/case',
});
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({
sessionId: SESSION_ID,
kind: 'permission',
toolName: 'Bash',
toolSummary: 'rm -rf node_modules',
message: 'Claude needs your permission to use Bash',
});
expect(approvals[0].options).toHaveLength(3);
expect(String(approvals[0].context)).toContain('permission to use Bash');
});
it('broadcast and push for the prompt carry the approvalId', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
const hookBroadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(hookBroadcast?.[1]).toMatchObject({ approvalId: item.id });
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(push?.[1]).toMatchObject({ approvalId: item.id });
});
it('answering with a parsed option sends exactly that digit (no Enter)', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'option', option: 2 },
});
expect(res.statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['2']);
expect(await listApprovals(harness)).toHaveLength(0);
});
it('approve sends "1", deny sends Esc', async () => {
await postHook(harness, 'permission_prompt', {});
let [item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(session.writeBuffer).toEqual(['1']);
session.writeBuffer.length = 0;
await postHook(harness, 'permission_prompt', {});
[item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'deny' },
});
expect(session.writeBuffer).toEqual(['\x1b']);
});
it('a second answer 404s (answered items leave the inbox)', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'deny' },
});
expect(res.statusCode).toBe(404);
expect(session.writeBuffer).toEqual(['1']);
});
it('rejects option digits outside the parsed options', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'option', option: 7 },
});
expect(res.statusCode).toBe(400);
expect(session.writeBuffer).toEqual([]);
});
it('refuses with 409 when the dialog left the screen since capture', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
// The dialog scrolled away, so the re-capture at answer time must refuse.
session.terminalBuffer = 'claude is off doing something else now';
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(409);
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(0);
});
it('idle prompts take a text answer, submitted with \\r; approve/deny are rejected', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
const [item] = await listApprovals(harness);
expect(item.kind).toBe('idle');
const bad = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(bad.statusCode).toBe(400);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'text', text: 'continue with the plan\nplease' },
});
expect(res.statusCode).toBe(200);
// Embedded newlines are flattened; the trailing \r submits.
expect(session.writeBuffer).toEqual(['continue with the plan please\r']);
});
it('text answers on dialog items are rejected', async () => {
await postHook(harness, 'elicitation_dialog', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'text', text: 'hello' },
});
expect(res.statusCode).toBe(400);
});
it('stop resolves the pending item; elicitation_complete resolves questions', async () => {
await postHook(harness, 'elicitation_dialog', {});
expect(await listApprovals(harness)).toHaveLength(1);
await postHook(harness, 'elicitation_complete', {});
expect(await listApprovals(harness)).toHaveLength(0);
await postHook(harness, 'permission_prompt', {});
expect(await listApprovals(harness)).toHaveLength(1);
await postHook(harness, 'stop', {});
expect(await listApprovals(harness)).toHaveLength(0);
});
it('a failed write restores the item and reports 422', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
session.failWrites = true;
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(422);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('dismiss removes without keystrokes', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(0);
});
describe('staleness sweep on GET /api/approvals', () => {
it('resolves an item whose dialog left the pane, and tells the other clients', async () => {
const resolved: Array<Record<string, unknown>> = [];
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
expect((await listApprovals(harness))[0].options).toHaveLength(3);
// Answered in the terminal: Claude Code fires no hook for that, so only
// the pane knows. The dialog is gone from the frame the next capture sees.
approvalInbox.onResolved = (info) => resolved.push({ ...info });
session.terminalBuffer = 'claude> back at the composer';
expect(await listApprovals(harness)).toHaveLength(0);
expect(resolved).toEqual([expect.objectContaining({ resolution: 'resolved_in_terminal' })]);
});
it('keeps an item whose dialog is still on screen', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
// Pane unchanged (PERMISSION_DIALOG): the human has not answered yet.
expect(await listApprovals(harness)).toHaveLength(1);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('never drops an item that could not be read in the first place', async () => {
// No parseable dialog at capture time, so a later "it does not parse" says
// nothing new. Conservative by design: an unreadable pane keeps the alert.
session.terminalBuffer = 'some output with no dialog in it';
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
expect(item.options).toBeUndefined();
session.terminalBuffer = 'still nothing that parses';
expect(await listApprovals(harness)).toHaveLength(1);
});
it('leaves idle prompts alone (they are not dialogs)', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});
session.terminalBuffer = 'claude> still waiting, different frame';
const [item] = await listApprovals(harness);
expect(item.kind).toBe('idle');
});
});
describe('an idle prompt from a session watching its own background work', () => {
beforeEach(() => {
session.terminalBuffer = 'claude> waiting at the composer';
session.watching = '1 monitor';
});
it('opens acknowledged, so no surface has an alert to raise', async () => {
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
const [item] = await listApprovals(harness);
expect(item).toMatchObject({ kind: 'idle', acknowledgedReason: 'watching 1 monitor' });
expect(item.acknowledgedAt).toEqual(expect.any(Number));
});
it('tells a live page why, so it declines to arm the alert', async () => {
await postHook(harness, 'idle_prompt', {});
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:idle_prompt');
expect(broadcast?.[1]).toMatchObject({ acknowledgedReason: 'watching 1 monitor' });
});
it('sends no push', async () => {
// The loudest surface, and the one a false alarm is hardest to ignore on.
await postHook(harness, 'idle_prompt', {});
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:idle_prompt')).toBeUndefined();
});
it('stays answerable from the drawer', async () => {
await postHook(harness, 'idle_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'text', text: 'carry on' },
});
expect(res.statusCode).toBe(200);
expect(session.writeBuffer.join('')).toContain('carry on');
});
it('still raises a permission dialog from the same session', async () => {
// Watching says nothing about a dialog: that one blocks the agent outright.
session.terminalBuffer = PERMISSION_DIALOG;
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
expect(item.acknowledgedAt).toBeUndefined();
expect(item.acknowledgedReason).toBeUndefined();
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(broadcast?.[1]).not.toMatchObject({ acknowledgedReason: expect.any(String) });
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt')).toBeDefined();
});
it('alerts normally again once the background work is over', async () => {
await postHook(harness, 'idle_prompt', {});
session.watching = null;
await postHook(harness, 'idle_prompt', {});
const [item] = await listApprovals(harness);
expect(item.acknowledgedAt).toBeUndefined();
expect(harness.ctx.sendPushNotifications.mock.calls.filter((c) => c[0] === 'hook:idle_prompt')).toHaveLength(1);
});
});
it('viewing a session acknowledges its idle prompt (item stays pending) and broadcasts it', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});
const [before] = await listApprovals(harness);
expect(before.acknowledgedAt).toBeUndefined();
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ sessionId: SESSION_ID, acknowledged: before.id });
// Seen, not answered: still listed (so it stays answerable), no keystrokes,
// and clients skip re-arming the tab alert because of acknowledgedAt.
const [after] = await listApprovals(harness);
expect(after.id).toBe(before.id);
expect(after.acknowledgedAt).toBeGreaterThan(0);
expect(session.writeBuffer).toEqual([]);
// Second view is a no-op (nothing new to tell the other devices).
const again = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(again.json().data.acknowledged).toBeNull();
});
it('viewing a session leaves a permission dialog alerting (looking is not answering)', async () => {
await postHook(harness, 'permission_prompt', {});
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().data.acknowledged).toBeNull();
const [item] = await listApprovals(harness);
expect(item.kind).toBe('permission');
expect(item.acknowledgedAt).toBeUndefined();
});
it('viewing an unknown session 404s', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/approvals/session/not-a-session/viewed',
payload: {},
});
expect(res.statusCode).toBe(404);
});
it('non-claude sessions never get inbox items', async () => {
session.mode = 'codex';
await postHook(harness, 'permission_prompt', {});
expect(await listApprovals(harness)).toHaveLength(0);
});
it('unknown ids 404 on answer and dismiss', async () => {
for (const url of ['/api/approvals/nope:1/answer', '/api/approvals/nope:1/dismiss']) {
const res = await harness.app.inject({
method: 'POST',
url,
payload: url.endsWith('answer') ? { action: 'approve' } : {},
});
expect(res.statusCode).toBe(404);
}
});
});
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
let harness: RouteTestHarness;
let session: MockSession;
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'deepseek';
session.terminalBuffer = 'some dsh dialog frame';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('creates an answerInTerminal inbox item and omits approvalId from push', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({
sessionId: SESSION_ID,
kind: 'permission',
answerInTerminal: true,
});
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(push?.[1]).toBeDefined();
expect(push?.[1]).not.toHaveProperty('approvalId');
});
it('refuses Approve/Deny with a terminal-only error', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/answered in the terminal/i);
expect(session.writeBuffer).toEqual([]);
// Item stays pending — it is an alert, not a failed answer.
expect(await listApprovals(harness)).toHaveLength(1);
});
});
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
let harness: RouteTestHarness;
let session: MockSession;
const noopDeps = {
broadcast: () => {},
broadcastSessionStateDebounced: () => {},
sendPushNotifications: () => {},
};
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('lists the item, and the page-load sweep keeps it', async () => {
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
});
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
// The hooks gate would say a codex session cannot have an item at all, which is false
// about the one the caller holds; the alert-only check runs first.
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
}
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('can still be dismissed', async () => {
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(await listApprovals(harness)).toHaveLength(0);
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
});
it("a non-admin neither lists nor answers another user's approvals (404, not 403)", async () => {
const harness = await createEnvelopeHarness({ username: 'bob', role: 'user' });
const session = harness.ctx.sessions.get(SESSION_ID)!;
session.terminalBuffer = PERMISSION_DIALOG;
(session as unknown as { owner?: string }).owner = 'alice';
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId: SESSION_ID, data: {} },
});
// The item exists in the store...
expect(approvalInbox.listPending()).toHaveLength(1);
const [item] = approvalInbox.listPending();
// ...but bob sees an empty list and cannot act on the id.
const list = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
expect(list.json().data.approvals).toHaveLength(0);
const answer = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(answer.statusCode).toBe(404);
expect(session.writeBuffer).toEqual([]);
await harness.app.close();
});
});