feat(communication): add project subrooms and isolated guest access

This commit is contained in:
2026-09-30 14:23:03 +02:00
parent 21fe4723e3
commit ea641bb4d0
19 changed files with 1454 additions and 2 deletions

View File

@@ -0,0 +1,129 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { CollaborationService } from "../src/modules/collaboration/service"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
test("project subrooms isolate tenants, guests, history and invitation lifecycles", { skip: !testDatabaseUrl }, async (t) => {
const fixture = await collaborationDatabase(["0075_project_collaboration.sql"])
const mails: { to: string; html: string }[] = []
const service = new CollaborationService(fixture.db, {
publicUrl: "https://owner.example", guestPepper: () => "a".repeat(64),
sendMail: async (to, subject, html) => { mails.push({ to, html }); return { success: true } },
})
const ownerId = randomUUID(), externalId = randomUUID(), colleagueId = randomUUID()
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'Owner',null),(2,'Partner',null)")
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@example.test',true),($2,'external@example.test',false),($3,'colleague@example.test',false)", [ownerId, externalId, colleagueId])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$2),(1,$2),(1,$3)", [ownerId, externalId, colleagueId])
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,notes) VALUES (1,1,'Secret project','{}',false,'Secret calculation'),(2,2,'Foreign project','{}',false,'Private')")
const owner = await service.userContext(ownerId, 1)
const external = await service.userContext(externalId, 2)
const externalOtherTenant = await service.userContext(externalId, 1)
const colleague = await service.userContext(colleagueId, 1)
const room = await service.createRoom(owner, { projectId: 1, name: "Gerüstbau" })
const sibling = await service.createRoom(owner, { projectId: 1, name: "Bauherr" })
await t.test("creation requires project authority and enforces owner tenant at SQL level", async () => {
await assert.rejects(service.createRoom(colleague, { projectId: 1, name: "Leak" }), /Berechtigung/)
await assert.rejects(service.createRoom(owner, { projectId: 2, name: "Leak" }), /Projekt nicht/)
await assert.rejects(fixture.pool.query("INSERT INTO collaboration_rooms(tenant_id,project_id,name,created_by) VALUES (1,2,'Leak',$1)", [ownerId]), /another tenant/)
await assert.rejects(fixture.pool.query("UPDATE collaboration_rooms SET project_id=null WHERE id=$1", [room.id]), /immutable/)
})
await service.send(owner.principal, room.id, { clientId: randomUUID(), text: "Before invitation" })
const invitation = await service.invite(owner, room.id, external.principal, { overviewAllowed: true })
await t.test("invitation binds exact user and tenant and grants no project or sibling access", async () => {
assert.equal((await service.invitations(external.principal)).length, 1)
assert.equal((await service.invitations(externalOtherTenant.principal)).length, 0)
await assert.rejects(service.accept(externalOtherTenant.principal, invitation.id), /nicht verfügbar/)
await assert.rejects(service.messages(external.principal, room.id), /kein Zugriff/)
await service.accept(external.principal, invitation.id)
await service.accept(external.principal, invitation.id)
assert.deepEqual((await service.messages(external.principal, room.id)).messages, [])
assert.deepEqual((await service.listRooms(external.principal)).map((r) => r.id), [room.id])
await assert.rejects(service.room(external.principal, sibling.id), /kein Zugriff/)
await assert.rejects(service.management(external, room.id), /Berechtigung/)
assert.equal("projectId" in await service.room(external.principal, room.id), false)
})
await t.test("messages are ordered, retried idempotently and paginated", async () => {
const clientId = randomUUID()
const [first, second] = await Promise.all([
service.send(external.principal, room.id, { clientId, text: "Hello" }),
service.send(external.principal, room.id, { clientId, text: "Hello" }),
])
assert.equal(first.id, second.id)
assert.equal(first.seq, 2)
await assert.rejects(service.send(external.principal, room.id, { clientId, text: "Different" }), /Auftrags-ID/)
for (let i = 0; i < 53; i++) await service.send(owner.principal, room.id, { clientId: randomUUID(), text: `Message ${i}` })
const latest = await service.messages(external.principal, room.id)
assert.equal(latest.messages.length, 50)
assert.equal(latest.messages[49].seq, 55)
const earlier = await service.messages(external.principal, room.id, { before: latest.messages[0].seq })
assert.equal(earlier.messages[0].seq, 2)
await service.markRead(external.principal, room.id, 9999)
await service.markRead(external.principal, room.id, 1)
assert.equal((await service.room(external.principal, room.id)).lastReadSeq, 55)
})
await t.test("shared overview is an explicit field projection", async () => {
await assert.rejects(service.updateRoom(owner, room.id, { overview: { notes: "Secret" } }), /Unbekanntes Feld/)
await service.updateRoom(owner, room.id, { overview: { title: "External title", date: "2026-10-01" } })
assert.deepEqual((await service.room(external.principal, room.id)).overview, { title: "External title", date: "2026-10-01" })
await assert.rejects(service.updateRoom(owner, room.id, { projectId: 2 }), /Unbekanntes/)
})
await t.test("guest verifies invited address and gets only the accepted room", async () => {
const guest = await service.guest(owner, { email: "guest@example.test", name: "Guest" })
const invite = await service.invite(owner, sibling.id, guest, { role: "reader" })
const challenge = await service.requestGuestCode({ invitationToken: invite.token }, "test-guest")
assert.equal(mails[0].to, "guest@example.test")
const code = mails[0].html.match(/<strong>(\d+)<\/strong>/)![1]
await assert.rejects(service.verifyGuestCode({ challengeId: challenge.challengeId, code: "wrong" }, "test-guest"), /ungültig/)
const session = await service.verifyGuestCode({ challengeId: challenge.challengeId, code }, "test-guest")
await assert.rejects(service.verifyGuestCode({ challengeId: challenge.challengeId, code }, "test-guest"), /ungültig/)
const authenticated = await service.guestSession(session.token)
await assert.rejects(service.accept(authenticated, invite.id, "bad-token"), /nicht verfügbar/)
await service.accept(authenticated, invite.id, invite.token)
assert.deepEqual((await service.listRooms(authenticated)).map((r) => r.id), [sibling.id])
assert.equal((await service.room(authenticated, sibling.id)).overview, null)
await assert.rejects(service.send(authenticated, sibling.id, { clientId: randomUUID(), text: "No" }), /nur lesbar/)
await assert.rejects(service.room(authenticated, room.id), /kein Zugriff/)
const login = await service.requestGuestCode({ workspaceId: session.workspaceId, email: "guest@example.test" }, "test-login")
for (let i = 0; i < 5; i++) await assert.rejects(service.verifyGuestCode({ challengeId: login.challengeId, code: "wrong" }, "test-login"))
const correct = mails[1].html.match(/<strong>(\d+)<\/strong>/)![1]
await assert.rejects(service.verifyGuestCode({ challengeId: login.challengeId, code: correct }, "test-login"))
const guestMembership = (await service.management(owner, sibling.id)).members.find((m) => m.principalId === guest.id)
await service.revoke(owner, sibling.id, guestMembership.id)
await assert.rejects(service.room(authenticated, sibling.id), /kein Zugriff/)
await assert.rejects(service.accept(authenticated, invite.id, invite.token), /kein Zugriff/)
await service.logoutGuest(session.token)
await assert.rejects(service.guestSession(session.token), /Gastanmeldung/)
})
await t.test("expired and withdrawn invitations cannot add a member", async () => {
const invite = await service.invite(owner, sibling.id, colleague.principal, {})
await service.revoke(owner, sibling.id, invite.id, true)
await assert.rejects(service.accept(colleague.principal, invite.id), /widerrufen/)
const expired = await service.invite(owner, sibling.id, colleague.principal, {})
await fixture.pool.query("UPDATE collaboration_invitations SET expires_at=now()-interval '1 minute' WHERE id=$1", [expired.id])
await assert.rejects(service.accept(colleague.principal, expired.id), /abgelaufen/)
})
await t.test("revocation blocks old accept/retry and reinvitation resets history", async () => {
const member = (await service.management(owner, room.id)).members.find((m) => m.principalId === external.principal.id)
await service.revoke(owner, room.id, member.id)
await assert.rejects(service.messages(external.principal, room.id), /kein Zugriff/)
await assert.rejects(service.accept(external.principal, invitation.id), /kein Zugriff/)
const reInvite = await service.invite(owner, room.id, external.principal, {})
await service.accept(external.principal, reInvite.id)
assert.deepEqual((await service.messages(external.principal, room.id)).messages, [])
await service.updateRoom(owner, room.id, { status: "archived" })
await assert.rejects(service.send(external.principal, room.id, { clientId: randomUUID(), text: "No" }), /archiviert/)
await service.messages(external.principal, room.id)
await service.updateRoom(owner, room.id, { status: "blocked" })
await assert.rejects(service.messages(external.principal, room.id), /gesperrt/)
})
} finally { await fixture.close() }
})

View File

@@ -0,0 +1,63 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import Fastify from "fastify"
import jwt from "jsonwebtoken"
import cookie from "@fastify/cookie"
import authPlugin from "../src/plugins/auth"
import { secrets } from "../src/utils/secrets"
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
import { CollaborationService } from "../src/modules/collaboration/service"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip: !testDatabaseUrl }, async () => {
const fixture = await collaborationDatabase(["0075_project_collaboration.sql"])
const app = Fastify(), ownerId = randomUUID(), mail: string[] = []
const previousSecret = secrets.JWT_SECRET
secrets.JWT_SECRET = "collaboration-test-only-secret-" + randomUUID()
const service = new CollaborationService(fixture.db, { publicUrl: "https://owner.example", guestPepper: () => "p".repeat(64),
sendMail: async (to, subject, html) => { mail.push(html); return { success: true } } })
const options = { service, apiUrl: "https://owner.example/backend" }
try {
await fixture.pool.query("INSERT INTO tenants VALUES (1,'Owner',null)")
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@example.test',true)", [ownerId])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [ownerId])
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Internal','{}',false)")
app.decorate("db", fixture.db)
await app.register(cookie)
await app.register(collaborationGuestRoutes, options)
await app.register(async protectedApp => {
await protectedApp.register(authPlugin)
await protectedApp.register(collaborationRoutes, options)
}, { prefix: "/api" })
const ownerToken = jwt.sign({ user_id: ownerId, tenant_id: 1, is_admin: true }, secrets.JWT_SECRET)
const headers = { authorization: `Bearer ${ownerToken}` }
const created = await app.inject({ method: "POST", url: "/api/collaboration/rooms", headers, payload: { projectId: 1, name: "Guest room" } })
assert.equal(created.statusCode, 200, created.body)
const roomId = created.json().id
const invited = await app.inject({ method: "POST", url: `/api/collaboration/rooms/${roomId}/invitations`, headers,
payload: { kind: "guest", email: "guest@example.test", name: "Guest", sendEmail: false } })
assert.equal(invited.statusCode, 200, invited.body)
const token = new URL(invited.json().url).hash.slice("#invite=".length)
const requested = await app.inject({ method: "POST", url: "/guest/api/auth/request", payload: { invitationToken: token } })
assert.equal(requested.statusCode, 200, requested.body)
const verified = await app.inject({ method: "POST", url: "/guest/api/auth/verify", payload: {
challengeId: requested.json().challengeId, code: mail[0].match(/<strong>(\d+)<\/strong>/)![1],
} })
assert.equal(verified.statusCode, 200, verified.body)
const guestHeaders = { authorization: `Bearer ${verified.json().token}` }
const accepted = await app.inject({ method: "POST", url: `/guest/api/invitations/${invited.json().id}/accept`, headers: guestHeaders, payload: { token } })
assert.equal(accepted.statusCode, 200, accepted.body)
assert.equal((await app.inject({ url: "/api/collaboration", headers: guestHeaders })).statusCode, 401)
assert.equal((await app.inject({ url: "/guest/api/rooms", headers })).statusCode, 401)
assert.equal((await app.inject({ url: `/guest/api/rooms/${roomId}`, headers: guestHeaders })).statusCode, 200)
assert.equal((await app.inject({ url: `/guest/api/rooms/${randomUUID()}`, headers: guestHeaders })).statusCode, 404)
const sent = await app.inject({ method: "POST", url: `/guest/api/rooms/${roomId}/messages`, headers: guestHeaders,
payload: { text: "Guest message", clientId: randomUUID(), tenantId: 999, principalId: ownerId } })
assert.equal(sent.statusCode, 200, sent.body)
const stored = await fixture.pool.query("SELECT p.kind FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.id=$1", [sent.json().id])
assert.equal(stored.rows[0].kind, "guest")
assert.equal((await app.inject({ method: "POST", url: "/guest/api/logout", headers: guestHeaders })).statusCode, 200)
assert.equal((await app.inject({ url: "/guest/api/rooms", headers: guestHeaders })).statusCode, 401)
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
})

View File

@@ -30,7 +30,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
})
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 TABLE tenants (id bigint PRIMARY KEY, name text, locked text, locked_by_export_job_id uuid)`)
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) {