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