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