diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index a2d7cc63..e94ef16a 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -88,13 +88,13 @@ export default async function communicationRoutes(server: FastifyInstance) { .onConflictDoNothing() } - const ensureGeneralRoom = async (tenantId: number, creatorId: string) => { - let [room] = await server.db.select().from(communicationRooms) - .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) - .limit(1) + const ensureGeneralRoom = async (tenantId: number, creatorId: string) => server.db.transaction(async (tx) => { + const generalKey = and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")) + let [room] = await tx.select().from(communicationRooms) + .where(generalKey).limit(1).for("update") if (!room) { - const [created] = await server.db.insert(communicationRooms).values({ + const [created] = await tx.insert(communicationRooms).values({ tenantId, key: "allgemein", name: "Allgemeiner Chat", @@ -104,16 +104,28 @@ export default async function communicationRoutes(server: FastifyInstance) { }).onConflictDoNothing().returning() room = created if (!room) { - [room] = await server.db.select().from(communicationRooms) - .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))) - .limit(1) + [room] = await tx.select().from(communicationRooms) + .where(generalKey).limit(1).for("update") } } - if (room.type !== "general" || room.archived) chatError("Allgemeiner Chat ist nicht verfügbar", 409) - await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId)) + // Old Matrix general-room endpoints omitted the type and persisted its default 'room'. + // Only unarchived, unbound tenant rooms may be adopted; a key collision must not grant access. + if (!room || !["general", "room"].includes(room.type) || room.archived + || room.entityType !== null || room.entityId !== null || room.entityUuid !== null) { + chatError("Allgemeiner Chat ist nicht verfügbar", 409) + } + if (room.type === "room") { + [room] = await tx.update(communicationRooms).set({ type: "general" }) + .where(eq(communicationRooms.id, room.id)).returning() + } + const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) + .where(eq(authTenantUsers.tenant_id, tenantId)) + if (users.length) await tx.insert(communicationRoomMembers) + .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) + .onConflictDoNothing() return room - } + }) const requireRoom = async (req: any, tenantId: number, userId: string, roomKey: string) => { const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms) diff --git a/backend/tests/communicationAccess.test.ts b/backend/tests/communicationAccess.test.ts index a3f0483e..15211de9 100644 --- a/backend/tests/communicationAccess.test.ts +++ b/backend/tests/communicationAccess.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" +import { randomUUID } from "node:crypto" import Fastify from "fastify" import communicationRoutes from "../src/routes/communication" import { assertCustomRoomKey } from "../src/modules/communication-access" @@ -10,6 +11,71 @@ test("custom room keys cannot impersonate system rooms", () => { assert.doesNotThrow(() => assertCustomRoomKey("baustelle")) }) +test("general chat accepts legacy room metadata without exposing protected rooms", { skip: !testDatabaseUrl }, async (t) => { + const fixture = await collaborationDatabase(), app = Fastify() + const user = randomUUID(), other = randomUUID(), legacyRoom = randomUUID(), otherTenantRoom = randomUUID() + try { + await fixture.pool.query("INSERT INTO tenants VALUES (1,'Legacy tenant',null),(2,'Other tenant',null)") + await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'user@example.test'),($2,'other@example.test')", [user, other]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(1,$2),(2,$2)", [user, other]) + // Matrix's general-room endpoints supplied key/name but omitted type, defaulting to 'room'. + await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,topic,type,archived) + VALUES ($1,1,'allgemein','Allgemeiner Chat','Existing topic','room',false), + ($2,2,'allgemein','Allgemeiner Chat',null,'room',false)`, [legacyRoom, otherTenantRoom]) + await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [legacyRoom, other]) + await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Existing history')", [legacyRoom, other]) + app.decorate("db", fixture.db) + app.addHook("preHandler", async (req: any) => { req.user = { tenant_id: 1, user_id: user }; req.hasPermission = () => false }) + await app.register(communicationRoutes) + + await t.test("concurrent legacy loads preserve the room, history and tenant boundary", async () => { + const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")]) + for (const response of responses) { + assert.equal(response.statusCode, 200, response.body) + assert.equal(response.json().rooms.length, 1) + assert.equal(response.json().rooms[0].id, legacyRoom) + assert.equal(response.json().rooms[0].type, "general") + assert.equal(response.json().rooms[0].topic, "Existing topic") + } + const history = await app.inject("/communication/chat/rooms/allgemein/messages") + assert.equal(history.statusCode, 200, history.body) + assert.equal(history.json().messages[0].body, "Existing history") + assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members WHERE room_id=$1", [legacyRoom])).rows[0].count, "2") + assert.equal((await fixture.pool.query("SELECT type FROM communication_rooms WHERE id=$1", [otherTenantRoom])).rows[0].type, "room") + }) + + for (const candidate of [ + { type: "direct" }, { type: "project" }, { type: "room", entityType: "project", entityId: 42 }, + { type: "room", entityId: 42 }, { type: "room", entityUuid: other }, + { type: "general", entityType: "project", entityId: 42 }, + { type: "room", archived: true }, { type: "general", archived: true }, + ]) await t.test(`protected general-key collision is not converted: ${JSON.stringify(candidate)}`, async () => { + const roomId = randomUUID() + await fixture.pool.query("UPDATE communication_rooms SET key='preserved-legacy' WHERE id=$1", [legacyRoom]) + await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_type,entity_id,entity_uuid) + VALUES ($1,1,'allgemein','Protected',$2,$3,$4,$5,$6)`, + [roomId, candidate.type, candidate.archived || false, candidate.entityType || null, candidate.entityId || null, candidate.entityUuid || null]) + await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [roomId, other]) + try { + const response = await app.inject("/communication/chat/rooms") + assert.equal(response.statusCode, 409, response.body) + const stored = (await fixture.pool.query("SELECT type,archived FROM communication_rooms WHERE id=$1", [roomId])).rows[0] + assert.equal(stored.type, candidate.type) + assert.equal(stored.archived, candidate.archived || false) + assert.deepEqual((await fixture.pool.query("SELECT user_id FROM communication_room_members WHERE room_id=$1", [roomId])).rows.map(m => m.user_id), [other]) + } finally { + await fixture.pool.query("DELETE FROM communication_rooms WHERE id=$1", [roomId]) + } + }) + + await t.test("concurrent first use creates one general room", async () => { + const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")]) + for (const response of responses) assert.equal(response.statusCode, 200, response.body) + assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_rooms WHERE tenant_id=1 AND key='allgemein'")).rows[0].count, "1") + }) + } finally { await app.close(); await fixture.close() } +}) + test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => { const fixture = await collaborationDatabase() const app = Fastify()