feat(appservice): room provisioning (M4c) (#535)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled

This commit was merged in pull request #535.
This commit is contained in:
2026-06-11 19:50:55 +00:00
parent b79e9f32c6
commit 799df40f4e
5 changed files with 202 additions and 2 deletions

View File

@@ -137,6 +137,97 @@ describe('AppserviceDaemon routing', () => {
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(