244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AppserviceDaemon } from '../server.js';
|
|
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
|
|
|
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>): 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<string, unknown>;
|
|
expect(body.room_alias_name).toBe('mosaic-proj-x');
|
|
expect((body.power_level_content_override as Record<string, unknown>).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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|