Files
Codeman/test/routes/inbox-routes.test.ts
T
RandalixandClaude Opus 5.5 a1a33cf050 fix(agent-inbox): review round 1
- codeman agent post: accept text that starts with "-" (markdown from a
  file), --json and -h stay options.
- DELETE /api/sessions/:id on a persisted-only row drops the inbox a detach
  kept.
- inbox --wait: wait.ended is exit 3 only when the session list no longer has
  the session; a restart or detach ending the wait is exit 2 (wait again).
- A read whose client hung up during ?wait= marks nothing seen.
- InboxPostSchema bound is 4x the byte cap, so every oversize message gets
  the store's "post a file path" answer.
- Store: restore sorts oldest first; the TTL sweep checks every message,
  not only the first.
- Docs: admins can act as any session of the receiver's owner (now tested
  for admin and non-admin); a peek with ?wait= does not block; exit codes.
- Tests: partial PUT /api/settings keeps persistence on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 12:45:48 +02:00

282 lines
12 KiB
TypeScript
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.
/**
* @fileoverview Agent inbox routes (src/web/routes/inbox-routes.ts) via app.inject().
*
* The caller is whoever `X-Codeman-Parent-Session` resolves to (live session, same
* owner as the receiver). These tests pin what that decides: who a post is from,
* whether a read marks seen or is a peek, and who may ack. Plus the wait rule (parks
* while nothing is UNSEEN), the full-inbox 409 and the owner-scoped summary.
*
* The routes use the process-wide `agentInbox` and `sessionWaits`; every test resets
* the inbox and cancels its waiters.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerInboxRoutes } from '../../src/web/routes/inbox-routes.js';
import { agentInbox, MAX_MESSAGES_PER_INBOX } from '../../src/web/agent-inbox.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode, type AuthUser } from '../../src/types.js';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
const RX = 'aaaaaaaa-1111-4111-8111-111111111111';
const TX = 'bbbbbbbb-2222-4222-8222-222222222222';
const OTHER = 'cccccccc-3333-4333-8333-333333333333';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
let currentUser: AuthUser | undefined;
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: RX });
for (const id of [TX, OTHER]) ctx.sessions.set(id, createMockSession(id) as never);
app.decorateRequest('authUser', undefined);
app.addHook('onRequest', async (req) => {
req.authUser = currentUser;
});
registerInboxRoutes(app, ctx as never);
// server.ts's envelope hook: an error envelope carries its 4xx.
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && p.success === false && reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const as = (caller?: string) => (caller ? { 'x-codeman-parent-session': caller } : {});
describe('agent inbox routes', () => {
let h: Harness;
const post = (to: string, text: string, caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${to}/inbox`, payload: { text }, headers: as(caller) });
const read = (of: string, caller?: string, query = '') =>
h.app.inject({ method: 'GET', url: `/api/sessions/${of}/inbox${query}`, headers: as(caller) });
const ack = (of: string, ids: string[], caller?: string) =>
h.app.inject({ method: 'POST', url: `/api/sessions/${of}/inbox/ack`, payload: { ids }, headers: as(caller) });
const data = (res: { body: string }) => JSON.parse(res.body).data;
beforeEach(async () => {
agentInbox.resetForTests();
currentUser = undefined;
delete process.env.CODEMAN_MULTIUSER;
h = await createHarness();
});
afterEach(async () => {
for (const id of [RX, TX, OTHER]) sessionWaits.cancelAll(id);
await h.app.close();
agentInbox.resetForTests();
delete process.env.CODEMAN_MULTIUSER;
});
describe('from', () => {
it('is the resolved caller session (exact id or 8-char prefix)', async () => {
expect(data(await post(RX, 'hi', TX)).message.from).toBe(TX);
expect(data(await post(RX, 'hi', TX.slice(0, 8))).message.from).toBe(TX);
});
it('is null (the user) without a header, for an unknown id, or for a short prefix', async () => {
expect(data(await post(RX, 'a')).message.from).toBeNull();
expect(data(await post(RX, 'b', 'dddddddd-0000-4000-8000-000000000000')).message.from).toBeNull();
expect(data(await post(RX, 'c', TX.slice(0, 7))).message.from).toBeNull();
});
it('a body field cannot set it: the schema rejects unknown keys', async () => {
const res = await h.app.inject({
method: 'POST',
url: `/api/sessions/${RX}/inbox`,
payload: { text: 'x', from: TX },
});
expect(res.statusCode).toBe(400);
});
it('multi-user, non-admin: a session the caller cannot see never resolves as the sender', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'user' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(OTHER) as unknown as { owner: string }).owner = 'alice';
expect(data(await post(RX, 'x', TX)).message.from).toBeNull();
expect(data(await post(RX, 'y', OTHER)).message.from).toBe(OTHER);
// Nor can alice read as bob's session or ack for it: bob's inbox is a 404 to her.
expect((await read(TX, TX)).statusCode).toBe(404);
expect((await ack(TX, ['m'], TX)).statusCode).toBe(404);
});
it('multi-user, admin: may act as any session of the receiver’s owner (admins see everything)', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'root', role: 'admin' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
expect(data(await post(RX, 'x', TX)).message.from).toBe(TX);
expect(data(await read(RX, RX)).receiver).toBe(true);
});
it('multi-user: a session of another owner never resolves as the sender', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'admin' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
(h.ctx.sessions.get(OTHER) as unknown as { owner: string }).owner = 'alice';
expect(data(await post(RX, 'x', TX)).message.from).toBeNull();
expect(data(await post(RX, 'y', OTHER)).message.from).toBe(OTHER);
});
});
describe('post', () => {
it('keeps multi-line text as data', async () => {
await post(RX, 'one\ntwo\n', TX);
expect(data(await read(RX, RX)).messages[0].text).toBe('one\ntwo\n');
});
it('a full inbox is 409 CONFLICT with a clear message, and the oldest stays', async () => {
const first = data(await post(RX, 'first')).message.id;
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) agentInbox.post(RX, null, String(i));
const res = await post(RX, 'one too many');
expect(res.statusCode).toBe(409);
expect(JSON.parse(res.body)).toMatchObject({ errorCode: 'CONFLICT' });
expect(JSON.parse(res.body).error).toMatch(/full .*Nothing was stored/);
expect(data(await read(RX)).messages[0].id).toBe(first);
});
it('a message over the byte cap is 400 with the store’s hint, multi-byte or not', async () => {
for (const text of ['é'.repeat(8193), 'x'.repeat(16_385)]) {
const res = await post(RX, text);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/Write it to a file in the workspace and post the path/);
}
});
it('404 for an unknown session', async () => {
expect((await post('nope', 'x')).statusCode).toBe(404);
});
});
describe('read', () => {
it('only the receiver marks seen; a coordinator read is a peek', async () => {
await post(RX, 'task', TX);
const peek = data(await read(RX, TX));
expect(peek).toMatchObject({ receiver: false, pending: 1, unseen: 1 });
expect(data(await read(RX)).unseen).toBe(1);
const own = data(await read(RX, RX));
expect(own).toMatchObject({ receiver: true, pending: 1, unseen: 0 });
expect(own.messages[0].seen).toBe(false);
expect(data(await read(RX, RX)).messages[0].seen).toBe(true);
});
it('?wait= parks while nothing is unseen, even with a seen-but-unacked message', async () => {
await post(RX, 'already seen', TX);
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
expect(sessionWaits.inboxWaiterCount(RX, true)).toBe(1);
await post(RX, 'new', TX);
const res = data(await parked);
expect(res.wait).toMatchObject({ posted: true, timedOut: false });
expect(res.messages.map((m: { text: string; seen: boolean }) => [m.text, m.seen])).toEqual([
['already seen', true],
['new', false],
]);
});
it('?wait= answers at once when something is unseen', async () => {
await post(RX, 'm', TX);
const res = data(await read(RX, RX, '?wait=60000'));
expect(res.wait).toMatchObject({ immediate: true, waitedMs: 0, timeoutMs: 60000 });
});
it('?wait= runs out as a 200 with timedOut, clamped to the wait floor', async () => {
const res = await read(RX, RX, '?wait=1');
expect(res.statusCode).toBe(200);
expect(data(res).wait).toMatchObject({ timedOut: true, timeoutMs: 1000 });
expect(res.headers['cache-control']).toBe('no-store');
});
it('?wait= rejects junk', async () => {
expect((await read(RX, RX, '?wait=-5')).statusCode).toBe(400);
expect((await read(RX, RX, '?wait=abc')).statusCode).toBe(400);
});
it('a parked read on a session that goes away answers ended, with an empty inbox', async () => {
await post(RX, 'x');
await read(RX, RX);
const parked = read(RX, RX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
// What server.ts does on delete: drop the inbox, then cancel the waiters.
agentInbox.drop(RX);
sessionWaits.cancelAll(RX);
const res = data(await parked);
expect(res.wait).toMatchObject({ ended: true });
expect(res.messages).toEqual([]);
});
});
describe('ack', () => {
it('removes by id for the receiver, idempotently', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
expect(data(await ack(RX, [id], RX))).toMatchObject({ removed: 1, pending: 0 });
const again = await ack(RX, [id, 'never-existed'], RX);
expect(again.statusCode).toBe(200);
expect(data(again)).toMatchObject({ removed: 0, pending: 0 });
});
it('is 403 for anyone but the receiver, and removes nothing', async () => {
const id = data(await post(RX, 'm', TX)).message.id;
for (const caller of [TX, undefined]) {
const res = await ack(RX, [id], caller);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
}
expect(agentInbox.pendingCount(RX)).toBe(1);
});
it('rejects an empty id list', async () => {
expect((await ack(RX, [], RX)).statusCode).toBe(400);
});
});
describe('summary', () => {
it('reports counts and parked per session, never text', async () => {
await post(RX, 'secret', TX);
await post(TX, 'other secret', RX);
await read(TX, TX);
const parked = read(OTHER, OTHER, '?wait=5000');
// A coordinator peeking with ?wait= is not the receiver: not "parked".
const peeking = read(RX, TX, '?wait=5000');
await new Promise((r) => setTimeout(r, 30));
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(res.body).not.toContain('secret');
expect(data(res).sessions).toEqual({
[RX]: { pending: 1, unseen: 1, oldestUnseenAt: expect.any(Number), parked: false },
[TX]: { pending: 1, unseen: 0, oldestUnseenAt: null, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
});
sessionWaits.cancelAll(OTHER);
sessionWaits.cancelAll(RX);
await Promise.all([parked, peeking]);
});
it('multi-user: lists only sessions the caller can see', async () => {
process.env.CODEMAN_MULTIUSER = '1';
currentUser = { username: 'alice', role: 'user' };
(h.ctx.sessions.get(RX) as unknown as { owner: string }).owner = 'alice';
(h.ctx.sessions.get(TX) as unknown as { owner: string }).owner = 'bob';
agentInbox.post(RX, null, 'mine');
agentInbox.post(TX, null, 'bob’s');
const res = await h.app.inject({ method: 'GET', url: '/api/agent-inbox/summary' });
expect(Object.keys(data(res).sessions)).toEqual([RX]);
});
});
});