fix(communication): adopt legacy general chat rooms safely

This commit is contained in:
2026-10-02 16:06:31 +02:00
parent b01cf2c207
commit 64299843e2
2 changed files with 89 additions and 11 deletions

View File

@@ -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))
return room
// 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)

View File

@@ -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()