import assert from "node:assert/strict" import test from "node:test" import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto" import Fastify from "fastify" import sharp from "sharp" import { CollaborationService } from "../src/modules/collaboration/service" import { FederationService, federationRoutes } from "../src/modules/collaboration/federation" import { instanceUrl, publicAddress, signedHeaders, verifyHeaders, Transport } from "../src/modules/collaboration/transport" import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" test("federation signatures bind body, origin, destination, key and short validity", () => { const keys = generateKeyPairSync("ed25519") const key = { keyId: randomUUID(), publicKey: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), privateKey: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString() } const target = "https://b.example/backend/federation/v1/rpc", body = '{"text":"Grüße"}' const headers = signedHeaders(key, "https://a.example/backend", target, body, 1000) assert.ok(verifyHeaders(key, target, body, headers, 1001).nonce) assert.throws(() => verifyHeaders(key, target, body + " ", headers, 1001), /digest/) assert.throws(() => verifyHeaders(key, target + "?other", body, headers, 1001), /Signatur/) assert.throws(() => verifyHeaders(key, target, body, { ...headers, "x-fedeo-origin": "https://other.example" }, 1001), /Signatur/) assert.throws(() => verifyHeaders({ ...key, keyId: randomUUID() }, target, body, headers, 1001), /Signaturprofil/) assert.throws(() => verifyHeaders(key, target, body, headers, 1121), /abgelaufen/) assert.throws(() => verifyHeaders(key, target, body, headers, 960), /abgelaufen/) assert.throws(() => verifyHeaders(key, target, body, { ...headers, "signature-input": headers["signature-input"].replace("ed25519", "hmac-sha256") }, 1001), /Signaturprofil/) }) test("federation destinations reject non-HTTPS and non-public IPv4/IPv6", () => { for (const value of ["http://example.com", "https://user:secret@example.com", "https://example.com:8443", "https://example.com/?x=1", "https://example.com/#frag"]) assert.throws(() => instanceUrl(value)) assert.equal(instanceUrl("https://EXAMPLE.com/backend/"), "https://example.com/backend") for (const value of ["127.0.0.1", "10.1.1.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.1.1", "192.0.2.1", "224.1.1.1", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "2001:db8::1", "2002:7f00:1::", "not-ip"]) assert.equal(publicAddress(value), false, value) assert.equal(publicAddress("8.8.8.8"), true) assert.equal(publicAddress("2606:4700:4700::1111"), true) }) test("two independent instances exchange invitations and messages through signed HTTP routes", { skip: !testDatabaseUrl }, async t => { const servers = new Map>() let offline = false, loseReply = false const transport: Transport = async (url, method, headers, body) => { if (offline) throw Object.assign(new Error("Simulated outage"), { statusCode: 503 }) const prefix = [...servers.keys()].find(origin => url.startsWith(origin + "/"))! const response = await servers.get(prefix)!.inject({ method, url: url.slice(prefix.length), headers, ...(body ? { payload: body } : {}) }) if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode }) if (loseReply && body && JSON.parse(body).action === "messages.send") { loseReply = false; throw Object.assign(new Error("Lost acknowledgement"), { statusCode: 503 }) } return response.json() } const make = async (name: string) => { const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"]) const userId = randomUUID() await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null),(2,'Other context',null)", [name]) await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`]) await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId]) await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Secret project','{}',false)") const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) }) const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport, seal: value => ({ testKey: value }), unseal: value => value.testKey }) service.options.changed = (id, db) => federation.changed(id, db) service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db) const app = Fastify() await app.register(federationRoutes, { federation }) await app.ready() servers.set(federation.origin, app) return { ...fixture, service, federation, app, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2) } } const a = await make("alpha"), b = await make("beta") try { await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true }) await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true }) const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal)) const room = await a.service.createRoom(a.ctx, { projectId: 1, name: "Gerüstbau" }) const sibling = await a.service.createRoom(a.ctx, { projectId: 1, name: "Bauherr" }) await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" }) const invitation = await a.service.invite(a.ctx, room.id, contact, { overviewAllowed: true }) await a.federation.tick() const offers = await b.federation.list(b.ctx) assert.equal(offers.invitations.length, 1) const bindingId = offers.invitations[0].id await t.test("named principal and owner tenant are checked independently of instance trust", async () => { assert.equal((await b.federation.list(b.other)).invitations.length, 0) assert.equal((await b.federation.accept(b.other, bindingId)).found, false) await assert.rejects(a.federation.resolveContact(a.ctx, b.federation.contact(b.other.principal)), /freigegeben/) await assert.rejects(b.federation.call(1, a.federation.origin, "invitation.accept", { actorId: b.other.principal.id, invitationId: invitation.id }), /nicht eingeladen/) const accepted = await b.federation.accept(b.ctx, bindingId) assert.equal(accepted.result.roomId, bindingId) await assert.rejects(b.federation.call(1, a.federation.origin, "room.get", { actorId: b.ctx.principal.id, roomId: sibling.id }), /kein Zugriff/) assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) }) await t.test("replay and destination substitution are rejected by actual HTTP route", async () => { const body = JSON.stringify({ version: 1, action: "room.get", actorId: b.ctx.principal.id, roomId: room.id }) const headers = signedHeaders(await b.federation.keys(), b.federation.origin, `${a.federation.origin}/federation/v1/rpc`, body) assert.equal((await a.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 200) assert.equal((await a.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 401) assert.equal((await b.app.inject({ method: "POST", url: "/federation/v1/rpc", headers, payload: body })).statusCode, 401) }) await t.test("lost acknowledgement, retries and concurrent delivery create exactly one message", async () => { await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Unpolled owner message" }) const command = { clientId: randomUUID(), text: "Hallo von Beta" } loseReply = true const pending = await b.federation.memberRequest(b.ctx, bindingId, "send", command) assert.equal(pending.result.pending, true) const jobs = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE binding_id=$1", [bindingId])).rows await Promise.all([b.federation.deliver(jobs[0]), b.federation.deliver(jobs[0])]) const retry = await b.federation.memberRequest(b.ctx, bindingId, "send", command) assert.equal(retry.result.body, command.text) assert.equal(retry.result.seq, 3) assert.equal((await a.service.messages(a.ctx.principal, room.id)).messages.length, 3) await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "send", { ...command, text: "Changed" }), /Auftrags-ID/) const history = (await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages assert.deepEqual(history.map((m: any) => m.body), ["Unpolled owner message", command.text]) }) await t.test("image-only messages and reactions cross signed routes, including retries and history restrictions", async () => { const mediaRoom = await a.service.createRoom(a.ctx, { name: "Media" }) const image = { name: "Foto.webp", dataUrl: `data:image/webp;base64,${(await sharp(randomBytes(700 * 500 * 3), { raw: { width: 700, height: 500, channels: 3 } }).webp({ quality: 85 }).toBuffer()).toString('base64')}` } assert.ok(image.dataUrl.length > 128 * 1024) const old = await a.service.send(a.ctx.principal, mediaRoom.id, { clientId: randomUUID(), image }) const invitation = await a.service.invite(a.ctx, mediaRoom.id, contact, {}) const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitation.id}`])).rows[0] await a.federation.deliver(job) const binding = (await b.federation.list(b.ctx)).invitations.find(i => i.name === "Media")!.id await b.federation.accept(b.ctx, binding) await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: old.id }), /kein Zugriff/) const command = { clientId: randomUUID(), image } loseReply = true assert.equal((await b.federation.memberRequest(b.ctx, binding, "send", command)).result.pending, true) const confirmed = (await b.federation.memberRequest(b.ctx, binding, "send", command)).result assert.ok(confirmed.seq) assert.equal(confirmed.body, "") assert.equal(confirmed.image.dataUrl, undefined) assert.equal((await a.service.messages(a.ctx.principal, mediaRoom.id)).messages.length, 2) const photo = (await b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id })).result assert.match(photo.dataUrl, /^data:image\/webp;base64,/) await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true }) await a.service.react(a.ctx.principal, mediaRoom.id, { messageId: confirmed.id, emoji: "🎉", active: true }) const reactions = (await b.federation.memberRequest(b.ctx, binding, "reactions", { ids: confirmed.id })).result.reactions assert.deepEqual(reactions[confirmed.id], [{ emoji: "🎉", count: 2, own: true }]) await b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: false }) assert.deepEqual((await a.service.reactions(a.ctx.principal, mediaRoom.id, { ids: confirmed.id })).reactions[confirmed.id], [{ emoji: "🎉", count: 1, own: true }]) const member = (await a.service.management(a.ctx, mediaRoom.id)).members.find(m => m.principalId === contact.id) await a.service.revoke(a.ctx, mediaRoom.id, member.id) await assert.rejects(b.federation.memberRequest(b.ctx, binding, "image", { messageId: confirmed.id }), /kein Zugriff/) await assert.rejects(b.federation.memberRequest(b.ctx, binding, "react", { messageId: confirmed.id, emoji: "🎉", active: true })) }) await t.test("only explicit overview is live-shared; offline cache omits it", async () => { await a.service.updateRoom(a.ctx, room.id, { overview: { title: "Freigegeben", description: "Treffpunkt Tor 2" } }) const live = await b.federation.memberRequest(b.ctx, bindingId, "room") assert.equal(live.result.overview.title, "Freigegeben") assert.equal("projectId" in live.result, false) offline = true const cached = await b.federation.memberRequest(b.ctx, bindingId, "room") assert.equal(cached.result.offline, true) assert.equal(cached.result.overview, null) const pending = await b.federation.memberRequest(b.ctx, bindingId, "send", { clientId: randomUUID(), text: "Offline" }) assert.equal(pending.result.pending, true) offline = false const job = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE status='pending' AND binding_id=$1", [bindingId])).rows[0] await b.federation.deliver(job) assert.equal((await a.service.messages(a.ctx.principal, room.id)).messages.at(-1).body, "Offline") }) await t.test("new invitations supersede stale active bindings; old notifications cannot revoke them", async () => { const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id) await a.service.revoke(a.ctx, room.id, member.id) const next = await a.service.invite(a.ctx, room.id, contact, {}) const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${next.id}`])).rows[0] await a.federation.deliver(job) assert.equal((await b.federation.list(b.ctx)).invitations.length, 1) const stale = { ...(await b.federation.binding(b.ctx, bindingId)), invitation_id: invitation.id } await b.federation.revokeBinding(stale) await b.federation.accept(b.ctx, bindingId) assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) }) await t.test("withdrawal removes a pending offer and delivery failures can be retried", async () => { const offer = await a.service.invite(a.ctx, sibling.id, contact, {}) const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${offer.id}`])).rows[0] await a.pool.query("UPDATE collaboration_outbox SET status='failed' WHERE id=$1", [job.id]) assert.equal((await a.federation.deliveries(a.ctx, sibling.id))[0].status, "failed") await a.federation.retryInvitation(a.ctx, sibling.id, offer.id) assert.equal((await b.federation.list(b.ctx)).invitations.length, 1) await a.service.revoke(a.ctx, sibling.id, offer.id, true) const withdrawal = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`withdraw:${offer.id}`])).rows[0] await a.federation.deliver(withdrawal) assert.equal((await b.federation.list(b.ctx)).invitations.length, 0) await assert.rejects(a.federation.retryInvitation(a.ctx, sibling.id, offer.id), /nicht erneut/) }) await t.test("rights changes fence queued commands and revocation purges cached content", async () => { const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id) offline = true await b.federation.memberRequest(b.ctx, bindingId, "send", { clientId: randomUUID(), text: "Never deliver" }) offline = false await a.service.revoke(a.ctx, room.id, member.id) const oldJob = (await b.pool.query("SELECT * FROM collaboration_outbox WHERE status='pending' AND binding_id=$1", [bindingId])).rows[0] await b.federation.deliver(oldJob) assert.equal((await b.pool.query("SELECT status FROM collaboration_outbox WHERE id=$1", [oldJob.id])).rows[0].status, "failed") await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "messages"), /kein Zugriff/) assert.equal((await b.pool.query("SELECT count(*) FROM collaboration_remote_messages WHERE binding_id=$1", [bindingId])).rows[0].count, "0") await assert.rejects(b.federation.call(1, a.federation.origin, "invitation.accept", { actorId: b.ctx.principal.id, invitationId: invitation.id }), /kein Zugriff/) const next = await a.service.invite(a.ctx, room.id, contact, {}) const job = (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${next.id}`])).rows[0] await a.federation.deliver(job) await b.federation.accept(b.ctx, bindingId) assert.deepEqual((await b.federation.memberRequest(b.ctx, bindingId, "messages")).result.messages, []) await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", oldJob.payload), /geändert/) b.federation.options.enabled = false await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "room"), /aktivieren/) b.federation.options.enabled = true await a.federation.settings(a.ctx, { origin: b.federation.origin, active: false }) await assert.rejects(b.federation.memberRequest(b.ctx, bindingId, "room"), /Partnerinstanz/) }) } finally { await a.app.close(); await b.app.close() await a.close(); await b.close() } })