Files
FEDEO/backend/tests/collaborationFederation.test.ts

177 lines
15 KiB
TypeScript

import assert from "node:assert/strict"
import test from "node:test"
import { generateKeyPairSync, randomUUID } from "node:crypto"
import Fastify from "fastify"
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("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()
}
})