fix(communication): enforce room and project membership boundaries
This commit is contained in:
50
backend/tests/communicationAccess.test.ts
Normal file
50
backend/tests/communicationAccess.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import Fastify from "fastify"
|
||||
import communicationRoutes from "../src/routes/communication"
|
||||
import { assertCustomRoomKey } from "../src/modules/communication-access"
|
||||
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||
|
||||
test("custom room keys cannot impersonate system rooms", () => {
|
||||
for (const key of ["allgemein", "direct_abc", "project_7"]) assert.throws(() => assertCustomRoomKey(key))
|
||||
assert.doesNotThrow(() => assertCustomRoomKey("baustelle"))
|
||||
})
|
||||
|
||||
test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase()
|
||||
const app = Fastify()
|
||||
const user = "00000000-0000-4000-8000-000000000001"
|
||||
const other = "00000000-0000-4000-8000-000000000002"
|
||||
const profile = "00000000-0000-4000-8000-000000000003"
|
||||
const room = "00000000-0000-4000-8000-000000000004"
|
||||
try {
|
||||
await fixture.pool.query(`INSERT INTO tenants VALUES (1, 'A', null), (2, 'B', null);
|
||||
INSERT INTO auth_users (id,email) VALUES ('${user}', 'a@example.test'), ('${other}', 'b@example.test');
|
||||
INSERT INTO auth_tenant_users (tenant_id,user_id) VALUES (1,'${user}'), (1,'${other}');
|
||||
INSERT INTO auth_profiles (id,tenant_id,user_id,first_name,last_name) VALUES ('${profile}',1,'${other}','B','Test');
|
||||
INSERT INTO projects (id,tenant,name,profiles,archived) VALUES (1,1,'Hidden',ARRAY['${profile}']::uuid[],false);
|
||||
INSERT INTO communication_rooms (id,tenant_id,key,name,type,archived) VALUES ('${room}',1,'private','Private','direct',false);
|
||||
INSERT INTO communication_room_members (room_id,user_id) VALUES ('${room}','${other}');`)
|
||||
app.decorate("db", fixture.db)
|
||||
app.addHook("preHandler", async (req: any) => {
|
||||
req.user = { tenant_id: 1, user_id: req.headers["x-test-user"] || user, is_admin: false }
|
||||
req.hasPermission = () => false
|
||||
})
|
||||
await app.register(communicationRoutes)
|
||||
const duplicate = await app.inject({ method: "POST", url: "/communication/chat/rooms", payload: { key: "private", name: "Open" } })
|
||||
assert.equal(duplicate.statusCode, 409)
|
||||
const memberships = await fixture.pool.query("SELECT user_id FROM communication_room_members WHERE room_id=$1", [room])
|
||||
assert.deepEqual(memberships.rows.map((row) => row.user_id), [other])
|
||||
const forbidden = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })
|
||||
assert.equal(forbidden.statusCode, 404)
|
||||
const list = await app.inject("/communication/chat/project-rooms")
|
||||
assert.deepEqual(list.json().rooms, [])
|
||||
const assigned = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision", headers: { "x-test-user": other } })
|
||||
assert.equal(assigned.statusCode, 200, assigned.body)
|
||||
await fixture.pool.query("UPDATE projects SET profiles='{}'::uuid[] WHERE id=1")
|
||||
const removed = await app.inject({ url: "/communication/chat/rooms/project_1/messages", headers: { "x-test-user": other } })
|
||||
assert.equal(removed.statusCode, 404)
|
||||
await fixture.pool.query("DELETE FROM auth_tenant_users WHERE user_id=$1", [user])
|
||||
assert.equal((await app.inject("/communication/chat/users")).statusCode, 403)
|
||||
} finally { await app.close(); await fixture.close() }
|
||||
})
|
||||
50
backend/tests/helpers/collaboration-db.ts
Normal file
50
backend/tests/helpers/collaboration-db.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { Pool } from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { getTableColumns, getTableName } from "drizzle-orm"
|
||||
import * as schema from "../../db/schema"
|
||||
|
||||
export const testDatabaseUrl = process.env.COLLABORATION_TEST_DATABASE_URL
|
||||
|
||||
/** Always creates and drops a private schema in the explicitly designated disposable test database. */
|
||||
export async function collaborationDatabase(migrations: string[] = []) {
|
||||
if (!testDatabaseUrl || new URL(testDatabaseUrl).pathname !== "/collaboration_test") {
|
||||
throw new Error("Tests require COLLABORATION_TEST_DATABASE_URL ending in /collaboration_test")
|
||||
}
|
||||
const namespace = `collaboration_test_${randomUUID().replaceAll("-", "")}`
|
||||
const admin = new Pool({ connectionString: testDatabaseUrl, max: 1 })
|
||||
await admin.query(`CREATE SCHEMA "${namespace}"`)
|
||||
const pool = new Pool({ connectionString: testDatabaseUrl, options: `-c search_path=${namespace}`, max: 6 })
|
||||
const db = drizzle(pool, { schema })
|
||||
try {
|
||||
await pool.query("CREATE TYPE locked_tenant AS ENUM ('none', 'maintenance_tenant')")
|
||||
for (const table of [schema.authUsers, schema.authTenantUsers, schema.authProfiles, schema.authRoles,
|
||||
schema.authUserRoles, schema.authRolePermissions, schema.projects, schema.communicationRooms]) {
|
||||
const columns = Object.values(getTableColumns(table)).map((column: any) => {
|
||||
let suffix = column.primary ? " PRIMARY KEY" : ""
|
||||
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
||||
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
||||
if (column.getSQLType() === "boolean") suffix += " DEFAULT false"
|
||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||
})
|
||||
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)
|
||||
}
|
||||
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text)`)
|
||||
await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`)
|
||||
await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8"))
|
||||
for (const name of migrations) {
|
||||
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
|
||||
}
|
||||
return { db, pool, close: async () => {
|
||||
await pool.end()
|
||||
await admin.query(`DROP SCHEMA "${namespace}" CASCADE`)
|
||||
await admin.end()
|
||||
} }
|
||||
} catch (error) {
|
||||
await pool.end()
|
||||
await admin.query(`DROP SCHEMA "${namespace}" CASCADE`)
|
||||
await admin.end()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user