kundencenter/apps/api/test/tickets.test.ts

113 lines
9.4 KiB
TypeScript

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);
});
});