Ticket-Anhänge: Bilder/PDF hochladen und anzeigen (geprüft anhand der Dateikennung)

This commit is contained in:
Kundencenter 2026-09-27 08:53:27 +02:00
parent 236c76b4e2
commit 012f0aa0db
12 changed files with 254 additions and 22 deletions

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });

View file

@ -1,7 +1,8 @@
import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { call, code, login, makeUser } from './helpers.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(); });
@ -55,3 +56,58 @@ describe('Support-Tickets', () => {
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);
});
});