import { beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/server.js'; import { randomBytes } from 'node:crypto'; import { call, code, login, makeUser, type Client } from './helpers.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildApp(); await app.ready(); }); async function staff(email: string, role: string) { await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; } async function customer(admin: any, name: string, mail: string) { const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json(); await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } }); return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client }; } describe('Support-Tickets', () => { it('Kunde eröffnet, Personal antwortet, interne Notiz bleibt verborgen, Zuweisung, Schließen, Mandantentrennung', async () => { const admin = await staff('tix-admin@example.com', 'admin'); const support = await staff('tix-support@example.com', 'support'); const accounting = await staff('tix-acc@example.com', 'accounting'); const A = await customer(admin, 'Firma A', 'tix-a@example.com'); const B = await customer(admin, 'Firma B', 'tix-b@example.com'); const create = await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Server langsam', body: 'Seit heute Morgen sehr langsam.' }); expect(create.statusCode).toBe(200); const id = create.json().id; // Kunde kann keine hohe Priorität setzen und nicht für einen fremden Kunden expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Test', body: 'y', priority: 'urgent' })).statusCode).toBe(403); expect((await call(app, A.client, 'POST', '/tickets', { orgId: B.org, subject: 'Test', body: 'y' })).statusCode).toBe(404); expect((await call(app, accounting, 'PATCH', `/tickets/${id}`, { priority: 'high' })).statusCode).toBe(403); // Buchhaltung nur lesend const get1 = (await call(app, support, 'GET', `/tickets/${id}`)).json(); expect(get1.status).toBe('pending_staff'); expect(get1.messages).toHaveLength(1); expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Interne Notiz: Ticket klingt nach Netzwerkproblem', internalNote: true })).statusCode).toBe(200); expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Wir prüfen das und melden uns.' })).statusCode).toBe(200); expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { assignedTo: null })).statusCode).toBe(200); // Kunde sieht die interne Notiz nicht, aber die Antwort; Status ist jetzt "wartet auf Kunde" const seenByCustomer = (await call(app, A.client, 'GET', `/tickets/${id}`)).json(); expect(seenByCustomer.status).toBe('pending_customer'); expect(seenByCustomer.messages).toHaveLength(2); expect(seenByCustomer.messages.some((m: any) => m.body.includes('Interne Notiz'))).toBe(false); expect((await call(app, B.client, 'GET', `/tickets/${id}`)).statusCode).toBe(404); // fremder Kunde // Kunde antwortet erneut → wartet auf Personal await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'Danke, bitte kurzfristig.' }); expect((await call(app, support, 'GET', `/tickets/${id}`)).json().status).toBe('pending_staff'); // Zuweisen, lösen, schließen expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { status: 'resolved' })).statusCode).toBe(200); expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(200); expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(409); // schon geschlossen expect((await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'noch was' })).statusCode).toBe(409); // Listen: Kunde sieht nur eigene, Personal sieht alle offenen const openB = (await call(app, admin, 'POST', '/tickets', { orgId: B.org, subject: 'Frage', body: 'Wie funktioniert X?' })).json(); expect((await call(app, B.client, 'GET', '/tickets')).json()).toHaveLength(1); const staffOpen = (await call(app, support, 'GET', '/tickets?status=open')).json(); expect(staffOpen.map((t: any) => t.id)).toContain(openB.id); expect(staffOpen.map((t: any) => t.id)).not.toContain(id); expect((await call(app, support, 'GET', '/tickets?status=closed')).json().map((t: any) => t.id)).toEqual([id]); }); }); /** Baut einen multipart/form-data-Body von Hand (ohne Bibliothek), ein Feld "file" je Eintrag. */ function multipart(files: { filename: string; mimetype: string; data: Buffer }[]): { body: Buffer; contentType: string } { const boundary = `----kc-test-${randomBytes(8).toString('hex')}`; const parts = files.map((f) => Buffer.concat([ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${f.filename}"\r\nContent-Type: ${f.mimetype}\r\n\r\n`), f.data, Buffer.from('\r\n'), ])); return { body: Buffer.concat([...parts, Buffer.from(`--${boundary}--\r\n`)]), contentType: `multipart/form-data; boundary=${boundary}` }; } function upload(client: Client, url: string, files: { filename: string; mimetype: string; data: Buffer }[]) { const { body, contentType } = multipart(files); return app.inject({ method: 'POST', url: `/v1${url}`, payload: body, headers: { cookie: client.cookie, 'x-csrf-token': client.csrf, 'content-type': contentType } }); } const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); const PDF = Buffer.from('%PDF-1.4 minimal test file, kein echtes PDF nötig für den Test'); describe('Ticket-Anhänge', () => { it('Bild/PDF werden anhand der Dateikennung geprüft, gespeichert und ausgeliefert; falscher Typ und fremde Nachricht werden abgelehnt', async () => { const admin = await staff('att-admin@example.com', 'admin'); const support = await staff('att-support@example.com', 'support'); const A = await customer(admin, 'Firma A', 'att-a@example.com'); const B = await customer(admin, 'Firma B', 'att-b@example.com'); const t = (await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Screenshot anbei', body: 'siehe Anhang' })).json(); const msgId = (await call(app, A.client, 'GET', `/tickets/${t.id}`)).json().messages[0].id; const bad = await upload(A.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'x.txt', mimetype: 'text/plain', data: Buffer.from('kein Bild') }]); expect(bad.statusCode).toBe(415); const okRes = await upload(A.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'schirm.png', mimetype: 'image/png', data: PNG }, { filename: 'beleg.pdf', mimetype: 'application/pdf', data: PDF }]); expect(okRes.statusCode).toBe(200); const atts = okRes.json().attachments; expect(atts).toHaveLength(2); expect(atts[0].contentType).toBe('image/png'); // fremder Kunde darf weder die Nachricht noch den Anhang sehen; nicht Autor darf nichts anhängen expect((await upload(B.client, `/tickets/${t.id}/messages/${msgId}/attachments`, [{ filename: 'x.png', mimetype: 'image/png', data: PNG }])).statusCode).toBe(404); expect((await call(app, B.client, 'GET', `/tickets/${t.id}/attachments/${atts[0].id}`)).statusCode).toBe(404); const dl = await call(app, A.client, 'GET', `/tickets/${t.id}/attachments/${atts[0].id}`); expect(dl.statusCode).toBe(200); expect(dl.headers['content-type']).toBe('image/png'); expect(Buffer.compare(dl.rawPayload, PNG)).toBe(0); const detail = (await call(app, support, 'GET', `/tickets/${t.id}`)).json(); expect(detail.messages[0].attachments.map((a: any) => a.filename).sort()).toEqual(['beleg.pdf', 'schirm.png']); // interne Notiz mit Anhang bleibt für den Kunden unsichtbar await call(app, support, 'POST', `/tickets/${t.id}/messages`, { body: 'interne Notiz', internalNote: true }); const noteMsgId = (await call(app, support, 'GET', `/tickets/${t.id}`)).json().messages.at(-1).id; await upload(support, `/tickets/${t.id}/messages/${noteMsgId}/attachments`, [{ filename: 'intern.png', mimetype: 'image/png', data: PNG }]); const custView = (await call(app, A.client, 'GET', `/tickets/${t.id}`)).json(); expect(custView.messages.some((m: any) => m.attachments.some((a: any) => a.filename === 'intern.png'))).toBe(false); const internAttId = (await call(app, support, 'GET', `/tickets/${t.id}`)).json().messages.find((m: any) => m.internalNote).attachments[0].id; expect((await call(app, A.client, 'GET', `/tickets/${t.id}/attachments/${internAttId}`)).statusCode).toBe(404); expect((await call(app, support, 'GET', `/tickets/${t.id}/attachments/${internAttId}`)).statusCode).toBe(200); // zu viele Dateien in einer Nachricht const manyMsgId = (await call(app, A.client, 'POST', `/tickets/${t.id}/messages`, { body: 'noch mehr Bilder' })).json().messageId; const many = Array.from({ length: 6 }, (_, i) => ({ filename: `b${i}.png`, mimetype: 'image/png', data: PNG })); expect((await upload(A.client, `/tickets/${t.id}/messages/${manyMsgId}/attachments`, many)).statusCode).toBe(400); }); }); describe('Ticket-Bezug: Ressource oder Domain', () => { it('Ticket kann sich auf eine Ressource oder eine Domain beziehen (nie beides), fremde Zuordnung wird abgelehnt', async () => { const admin = await staff('link-admin@example.com', 'admin'); const A = await customer(admin, 'Firma A', 'link-a@example.com'); const B = await customer(admin, 'Firma B', 'link-b@example.com'); const dom = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-test.de', orgId: A.org })).json(); const domB = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-testb.de', orgId: B.org })).json(); const tDom = (await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Domain zeigt Fehler', body: 'x', domainRecordId: dom.id })).json(); const detail = (await call(app, A.client, 'GET', `/tickets/${tDom.id}`)).json(); expect(detail.domainRecordId).toBe(dom.id); expect(detail.domain).toBe('link-test.de'); expect(detail.resourceId).toBeNull(); expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'fremde Domain', body: 'x', domainRecordId: domB.id })).statusCode).toBe(400); expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'beides', body: 'x', domainRecordId: dom.id, resourceId: dom.id })).statusCode).toBe(400); const list = (await call(app, admin, 'GET', '/tickets?status=open')).json(); expect(list.find((t: any) => t.id === tDom.id)?.domain).toBe('link-test.de'); }); });