feat(communication): add chat reactions emojis and images
This commit is contained in:
117
backend/tests/chatContent.test.ts
Normal file
117
backend/tests/chatContent.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import sharp from "sharp"
|
||||
import Fastify from "fastify"
|
||||
import { chatContent } from "../src/modules/chat-content"
|
||||
import { CollaborationService } from "../src/modules/collaboration/service"
|
||||
import communicationRoutes from "../src/routes/communication"
|
||||
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
|
||||
|
||||
const picture = async () => ({ name: "Baustelle.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 80, height: 40, channels: 3, background: '#206050' } }).png().toBuffer()).toString('base64')}` })
|
||||
|
||||
test("chat content accepts emoji and image-only messages, strips metadata and rejects unsafe input", async () => {
|
||||
assert.equal((await chatContent({ text: "Hallo 👋 ❤️" })).body, "Hallo 👋 ❤️")
|
||||
const normalized = await chatContent({ image: await picture() })
|
||||
assert.equal(normalized.body, "")
|
||||
assert.equal(normalized.image?.width, 80)
|
||||
const metadata = await sharp(Buffer.from(normalized.image!.dataUrl.split(',')[1], 'base64')).metadata()
|
||||
assert.equal(metadata.format, 'webp')
|
||||
assert.equal(metadata.exif, undefined)
|
||||
for (const input of [{}, { text: ' '.repeat(5) }, { text: 'a'.repeat(10001) },
|
||||
{ image: { dataUrl: 'https://example.test/tracker.png' } },
|
||||
{ image: { dataUrl: `data:image/png;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>').toString('base64')}` } },
|
||||
{ image: { dataUrl: 'data:image/png;base64,broken' } },
|
||||
{ image: { dataUrl: 'a'.repeat(700001) } }]) await assert.rejects(chatContent(input))
|
||||
})
|
||||
|
||||
test("native chat images and reactions follow tenant, membership, project and message boundaries", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase(), app = Fastify()
|
||||
const owner = randomUUID(), colleague = randomUUID(), outsider = randomUUID(), room = randomUUID(), otherRoom = randomUUID()
|
||||
try {
|
||||
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null),(2,'B',null)")
|
||||
for (const [id, tenant] of [[owner, 1], [colleague, 1], [outsider, 2]]) {
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,$2)", [id, `${id}@test.example`])
|
||||
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES ($1,$2)", [tenant, id])
|
||||
}
|
||||
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,chat_enabled) VALUES (1,1,'Project','{}',false,true)")
|
||||
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_id)
|
||||
VALUES ($1,1,'project_1','Project','project',false,1),($2,1,'other','Other','room',false,null)`, [room, otherRoom])
|
||||
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2),($1,$3),($4,$2)", [room, owner, colleague, otherRoom])
|
||||
app.decorate('db', fixture.db)
|
||||
app.addHook('preHandler', async (req: any) => {
|
||||
req.user = { user_id: req.headers['x-user'] || owner, tenant_id: Number(req.headers['x-tenant'] || 1), is_admin: true }
|
||||
req.hasPermission = () => true
|
||||
})
|
||||
await app.register(communicationRoutes)
|
||||
const base = '/communication/chat/rooms/project_1'
|
||||
const sent = await app.inject({ method: 'POST', url: `${base}/messages`, payload: { image: await picture() } })
|
||||
assert.equal(sent.statusCode, 200, sent.body)
|
||||
const message = sent.json()
|
||||
assert.equal(message.body, '')
|
||||
assert.equal(message.image.width, 80)
|
||||
assert.equal(message.image.dataUrl, undefined)
|
||||
assert.equal((await app.inject(`${base}/messages`)).json().messages[0].image.dataUrl, undefined)
|
||||
const image = await app.inject(`${base}/image?messageId=${message.id}`)
|
||||
assert.equal(image.statusCode, 200, image.body)
|
||||
assert.equal(image.headers['cache-control'], 'no-store')
|
||||
assert.match(image.json().dataUrl, /^data:image\/webp;base64,/)
|
||||
const react = (headers = {}, active = true, emoji = '👍') => app.inject({ method: 'PUT', url: `${base}/reactions`, headers, payload: { messageId: message.id, emoji, active } })
|
||||
assert.equal((await react()).statusCode, 200)
|
||||
assert.equal((await react()).json().reactions[message.id][0].count, 1)
|
||||
assert.equal((await react({ 'x-user': colleague })).json().reactions[message.id][0].count, 2)
|
||||
const removed = await react({}, false)
|
||||
assert.deepEqual(removed.json().reactions[message.id], [{ emoji: '👍', count: 1, own: false }])
|
||||
assert.equal((await react({}, true, '<script>')).statusCode, 400)
|
||||
assert.equal((await app.inject(`/communication/chat/rooms/other/image?messageId=${message.id}`)).statusCode, 404)
|
||||
assert.deepEqual((await app.inject(`/communication/chat/rooms/other/reactions?ids=${message.id}`)).json(), { reactions: {} })
|
||||
for (const headers of [{ 'x-user': outsider }, { 'x-user': outsider, 'x-tenant': '2' }]) {
|
||||
assert.ok([403, 404].includes((await app.inject({ url: `${base}/image?messageId=${message.id}`, headers })).statusCode))
|
||||
assert.ok([403, 404].includes((await react(headers)).statusCode))
|
||||
}
|
||||
await fixture.pool.query('UPDATE projects SET chat_enabled=false WHERE id=1')
|
||||
assert.equal((await app.inject(`${base}/image?messageId=${message.id}`)).statusCode, 404)
|
||||
assert.equal((await app.inject(`${base}/reactions?ids=${message.id}`)).statusCode, 404)
|
||||
assert.equal((await react()).statusCode, 404)
|
||||
} finally { await app.close(); await fixture.close() }
|
||||
})
|
||||
|
||||
test("subroom media respects history, guest read-only rights, revocation and idempotent sends", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase()
|
||||
const service = new CollaborationService(fixture.db, { publicUrl: 'https://example.test', guestPepper: () => 'test', sendMail: async () => ({ success: true }) })
|
||||
const ownerId = randomUUID()
|
||||
try {
|
||||
await fixture.pool.query("INSERT INTO tenants VALUES (1,'A',null)")
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@test.example',true)", [ownerId])
|
||||
await fixture.pool.query('INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)', [ownerId])
|
||||
const owner = await service.userContext(ownerId, 1)
|
||||
await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Project','{}',false)")
|
||||
const room = await service.createRoom(owner, { name: 'Media', projectId: 1 })
|
||||
const guest = await service.guest(owner, { name: 'Guest', email: 'guest@test.example' })
|
||||
const old = await service.send(owner.principal, room.id, { clientId: randomUUID(), image: await picture() })
|
||||
const invitation = await service.invite(owner, room.id, guest, {})
|
||||
await service.accept(guest, invitation.id, invitation.token)
|
||||
await assert.rejects(service.image(guest, room.id, { messageId: old.id }), /kein Zugriff/)
|
||||
await assert.rejects(service.react(guest, room.id, { messageId: old.id, emoji: '👍', active: true }), /kein Zugriff/)
|
||||
const command = { clientId: randomUUID(), text: '', image: await picture() }
|
||||
const sent = await service.send(guest, room.id, command)
|
||||
assert.equal((await service.send(guest, room.id, command)).id, sent.id)
|
||||
await assert.rejects(service.send(guest, room.id, { ...command, image: { ...command.image, name: 'different.png' } }), /Auftrags-ID/)
|
||||
assert.equal((await service.messages(guest, room.id)).messages[0].image.dataUrl, undefined)
|
||||
assert.match((await service.image(guest, room.id, { messageId: sent.id })).dataUrl, /^data:image\/webp/)
|
||||
await service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: true })
|
||||
await service.react(owner.principal, room.id, { messageId: sent.id, emoji: '❤️', active: true })
|
||||
assert.deepEqual((await service.reactions(guest, room.id, { ids: `${old.id},${sent.id}` })).reactions[sent.id], [{ emoji: '❤️', count: 2, own: true }])
|
||||
const member = (await service.management(owner, room.id)).members.find(m => m.principalId === guest.id)
|
||||
await service.updateMember(owner, room.id, member.id, { role: 'reader', overviewAllowed: false })
|
||||
await assert.rejects(service.react(guest, room.id, { messageId: sent.id, emoji: '❤️', active: false }), /nur lesbar/)
|
||||
await assert.rejects(service.send(guest, room.id, { clientId: randomUUID(), image: await picture() }), /nur lesbar/)
|
||||
await service.image(guest, room.id, { messageId: sent.id })
|
||||
await service.updateRoom(owner, room.id, { status: 'archived' })
|
||||
await assert.rejects(service.react(owner.principal, room.id, { messageId: sent.id, emoji: '👍', active: true }), /archiviert/)
|
||||
await service.updateRoom(owner, room.id, { status: 'active' })
|
||||
await service.revoke(owner, room.id, member.id)
|
||||
await assert.rejects(service.image(guest, room.id, { messageId: sent.id }), /kein Zugriff/)
|
||||
await assert.rejects(service.reactions(guest, room.id, { ids: sent.id }), /kein Zugriff/)
|
||||
} finally { await fixture.close() }
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto"
|
||||
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"
|
||||
@@ -104,6 +105,38 @@ test("two independent instances exchange invitations and messages through signed
|
||||
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")
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"
|
||||
import Fastify from "fastify"
|
||||
import jwt from "jsonwebtoken"
|
||||
import cookie from "@fastify/cookie"
|
||||
import sharp from "sharp"
|
||||
import authPlugin from "../src/plugins/auth"
|
||||
import { secrets } from "../src/utils/secrets"
|
||||
import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration"
|
||||
@@ -57,7 +58,19 @@ test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip
|
||||
assert.equal(sent.statusCode, 200, sent.body)
|
||||
const stored = await fixture.pool.query("SELECT p.kind FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.id=$1", [sent.json().id])
|
||||
assert.equal(stored.rows[0].kind, "guest")
|
||||
const image = { name: "Gastbild.png", dataUrl: `data:image/png;base64,${(await sharp({ create: { width: 2, height: 2, channels: 3, background: 'green' } }).png().toBuffer()).toString('base64')}` }
|
||||
const photo = await app.inject({ method: "POST", url: `/guest/api/rooms/${roomId}/messages`, headers: guestHeaders,
|
||||
payload: { clientId: randomUUID(), image } })
|
||||
assert.equal(photo.statusCode, 200, photo.body)
|
||||
assert.equal(photo.json().image.dataUrl, undefined)
|
||||
const imagePath = `/guest/api/rooms/${roomId}/image?messageId=${photo.json().id}`
|
||||
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 200)
|
||||
const reactionPath = `/guest/api/rooms/${roomId}/reactions`
|
||||
assert.equal((await app.inject({ method: "PUT", url: reactionPath, headers: guestHeaders,
|
||||
payload: { messageId: photo.json().id, emoji: '👍', active: true } })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: `${reactionPath}?ids=${photo.json().id}`, headers: guestHeaders })).json().reactions[photo.json().id][0].own, true)
|
||||
assert.equal((await app.inject({ method: "POST", url: "/guest/api/logout", headers: guestHeaders })).statusCode, 200)
|
||||
assert.equal((await app.inject({ url: "/guest/api/rooms", headers: guestHeaders })).statusCode, 401)
|
||||
assert.equal((await app.inject({ url: imagePath, headers: guestHeaders })).statusCode, 401)
|
||||
} finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() }
|
||||
})
|
||||
|
||||
@@ -33,7 +33,10 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
||||
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text, locked_by_export_job_id uuid)`)
|
||||
await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`)
|
||||
await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8"))
|
||||
for (const name of migrations) {
|
||||
const steps = [...migrations]
|
||||
if (!steps.includes("0075_project_collaboration.sql")) steps.unshift("0075_project_collaboration.sql")
|
||||
if (!steps.includes("0081_chat_media_reactions.sql")) steps.push("0081_chat_media_reactions.sql")
|
||||
for (const name of steps) {
|
||||
await pool.query(await readFile(new URL(`../../db/migrations/${name}`, import.meta.url), "utf8"))
|
||||
}
|
||||
return { db, pool, close: async () => {
|
||||
|
||||
Reference in New Issue
Block a user