fix(communication): adopt legacy general chat rooms safely
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user