import { describe, expect, it, vi } from 'vitest'; import { AppserviceDaemon } from '../server.js'; import type { DaemonConfig, DaemonRequest } from '../server.js'; const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents'; const cfg: DaemonConfig = { homeserverUrl: 'https://hs.example', domain: 'hs.example', asToken: 'as-secret', hsToken: 'hs-secret', bridgeTokens: ['bridge-secret'], }; const jsonResponse = (status: number, body: unknown): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); const request = (overrides: Partial): DaemonRequest => ({ method: 'GET', path: '/', searchParams: new URLSearchParams(), body: undefined, ...overrides, }); const makeDaemon = () => { const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' })); const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); return { daemon, fetchMock }; }; describe('AppserviceDaemon routing', () => { it('serves health unauthenticated', async () => { const { daemon } = makeDaemon(); expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200); }); it('404s unknown paths', async () => { const { daemon } = makeDaemon(); expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404); }); it('transactions require the hs_token', async () => { const { daemon } = makeDaemon(); const bad = await daemon.handle( request({ method: 'PUT', path: '/_matrix/app/v1/transactions/t1', authorizationHeader: 'Bearer wrong', body: { events: [] }, }), ); expect(bad.status).toBe(403); const ok = await daemon.handle( request({ method: 'PUT', path: '/_matrix/app/v1/transactions/t1', authorizationHeader: 'Bearer hs-secret', body: { events: [{ type: 'm.room.message', event_id: '$e' }] }, }), ); expect(ok.status).toBe(200); }); it('bridge requires a bridge token (hs/as tokens do not work)', async () => { const { daemon } = makeDaemon(); for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) { const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: token, body: {}, }), ); expect(res.status).toBe(403); } }); it('bridge message sends as the agent and returns the event id', async () => { const { daemon, fetchMock } = makeDaemon(); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: 'Bearer bridge-secret', body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' }, }), ); expect(res.status).toBe(200); expect(res.body.event_id).toBe('$sent'); const sendCall = fetchMock.mock.calls .map((c) => new URL(String(c[0]))) .find((u) => u.pathname.includes('/send/m.room.message/')); expect(sendCall).toBeDefined(); expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example'); }); it('bridge rejects invalid payloads with 400', async () => { const { daemon } = makeDaemon(); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: 'Bearer bridge-secret', body: { room_id: 'bad', agent: 'pi0', body: 'x' }, }), ); expect(res.status).toBe(400); }); it('bridge typing endpoint works', async () => { const { daemon, fetchMock } = makeDaemon(); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/typing', authorizationHeader: 'Bearer bridge-secret', body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true }, }), ); expect(res.status).toBe(200); const typingCall = fetchMock.mock.calls .map((c) => new URL(String(c[0]))) .find((u) => u.pathname.includes('/typing/')); expect(typingCall).toBeDefined(); }); it('authenticated unknown bridge sub-paths return 405, never fall through', async () => { const { daemon } = makeDaemon(); const res = await daemon.handle( request({ method: 'GET', path: '/bridge/v1/unknown', authorizationHeader: 'Bearer bridge-secret', }), ); expect(res.status).toBe(405); }); it('provisions a room as the AS sender with space linking', async () => { const calls: Array<{ url: URL; body: unknown }> = []; const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { const url = new URL(String(input)); calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); if (url.pathname.endsWith('/createRoom')) return jsonResponse(200, { room_id: '!new:hs.example' }); return jsonResponse(200, {}); }); const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/provision/rooms', authorizationHeader: 'Bearer bridge-secret', body: { name: 'proj-x', alias: 'mosaic-proj-x', invite: ['@jason.woltje:hs.example'], space_id: '!space:hs.example', }, }), ); expect(res.status).toBe(200); expect(res.body.room_id).toBe('!new:hs.example'); expect(res.body.space_linked).toBe(true); const create = calls.find((c) => c.url.pathname.endsWith('/createRoom')); expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example'); const body = create!.body as Record; expect(body.room_alias_name).toBe('mosaic-proj-x'); expect((body.power_level_content_override as Record).users).toEqual({ '@mosaic-as:hs.example': 100, }); expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true); expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true); }); it('space-link failure still returns the room id (no orphan)', async () => { const fetchMock = vi.fn(async (input: URL | string) => { const url = new URL(String(input)); if (url.pathname.endsWith('/createRoom')) return jsonResponse(200, { room_id: '!new:hs.example' }); if (url.pathname.includes('/state/m.space.child/')) return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' }); return jsonResponse(200, {}); }); const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/provision/rooms', authorizationHeader: 'Bearer bridge-secret', body: { name: 'proj-x', space_id: '!space:hs.example' }, }), ); expect(res.status).toBe(200); expect(res.body.room_id).toBe('!new:hs.example'); expect(res.body.space_linked).toBe(false); expect(String(res.body.space_error)).toContain('403'); }); it('invite list cap enforced', async () => { const { daemon } = makeDaemon(); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/provision/rooms', authorizationHeader: 'Bearer bridge-secret', body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) }, }), ); expect(res.status).toBe(400); }); it('provision rejects bad payloads and requires auth', async () => { const { daemon } = makeDaemon(); const noAuth = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }), ); expect(noAuth.status).toBe(403); const bad = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/provision/rooms', authorizationHeader: 'Bearer bridge-secret', body: { name: '', alias: 'BAD ALIAS' }, }), ); expect(bad.status).toBe(400); }); // A daemon whose fetch mock backs account_data with a mutable in-test object, // so register/verify/revoke round-trip through the (faked) homeserver. const makeAgentDaemon = () => { const accountData: { value: Record | null } = { value: null }; const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { const url = new URL(String(input)); const path = url.pathname; if (path.includes(`/account_data/${AGENTS_TYPE}`)) { if (init?.method === 'PUT') { accountData.value = JSON.parse(String(init.body)) as Record; return jsonResponse(200, {}); } if (accountData.value === null) { return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' }); } return jsonResponse(200, accountData.value); } if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' }); if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' }); return jsonResponse(200, {}); }); const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {}); return { daemon, fetchMock }; }; const registerAgent = async ( daemon: AppserviceDaemon, body: Record = { alias: 'pi0', host: 'web1' }, ) => daemon.handle( request({ method: 'POST', path: '/bridge/v1/agents', authorizationHeader: 'Bearer bridge-secret', body, }), ); it('host token registers an agent and returns agent_user_id + bridge_token', async () => { const { daemon, fetchMock } = makeAgentDaemon(); const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); expect(res.status).toBe(200); expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example'); expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true); const registerCall = fetchMock.mock.calls .map((c) => new URL(String(c[0]))) .find((u) => u.pathname.endsWith('/register')); expect(registerCall).toBeDefined(); }); it('register requires a HOST token (agent token and no token are 403)', async () => { const { daemon } = makeAgentDaemon(); const minted = await registerAgent(daemon); const agentToken = String(minted.body.bridge_token); const asAgent = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/agents', authorizationHeader: `Bearer ${agentToken}`, body: { alias: 'pi1', host: 'web2' }, }), ); expect(asAgent.status).toBe(403); const noAuth = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }), ); expect(noAuth.status).toBe(403); }); it('agent-scoped token may send as itself but not as another agent', async () => { const { daemon } = makeAgentDaemon(); const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); const agentToken = String(minted.body.bridge_token); const self = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: `Bearer ${agentToken}`, body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' }, }), ); expect(self.status).toBe(200); const other = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: `Bearer ${agentToken}`, body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' }, }), ); expect(other.status).toBe(403); expect(other.body.error).toBe('token not scoped to this agent'); }); it('revoked agent token is rejected on messages', async () => { const { daemon } = makeAgentDaemon(); const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' }); const agentToken = String(minted.body.bridge_token); const revoke = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/agents/revoke', authorizationHeader: 'Bearer bridge-secret', body: { agent_user_id: '@agent-pi0-web1:hs.example' }, }), ); expect(revoke.status).toBe(200); expect(revoke.body.revoked).toBe(1); const afterRevoke = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/messages', authorizationHeader: `Bearer ${agentToken}`, body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' }, }), ); expect(afterRevoke.status).toBe(403); }); it('GET /bridge/v1/agents lists registered agents (host only)', async () => { const { daemon } = makeAgentDaemon(); await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' }); const res = await daemon.handle( request({ method: 'GET', path: '/bridge/v1/agents', authorizationHeader: 'Bearer bridge-secret', }), ); expect(res.status).toBe(200); const agents = res.body.agents as Array>; expect(agents).toHaveLength(1); expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example'); expect(agents[0]?.display_name).toBe('Pi Zero'); }); it('empty bridge token list denies everything', async () => { const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {}); const res = await daemon.handle( request({ method: 'POST', path: '/bridge/v1/typing', authorizationHeader: 'Bearer bridge-secret', body: {}, }), ); expect(res.status).toBe(403); }); });