210 lines
18 KiB
TypeScript
210 lines
18 KiB
TypeScript
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<string, ReturnType<typeof Fastify>>()
|
|
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()
|
|
}
|
|
})
|