import { BadGatewayException, BadRequestException, ConflictException, HttpException, } from '@nestjs/common'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { BugReportsService } from './bug-reports.service'; /** * BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf). * * Zehn Tests, darunter die vier Falsifizierungen des Plans: * (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem * Fenster (Fake-Timer) wieder durch; * (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet; * (c) weder Feld noch Umgebungsvariable -> 409, Leerstring zaehlt als * ungesetzt, nie versendet; * (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der * Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen * ausschliesslich mit der Kennung aus dem Sitzungsnachweis. * Test 9 und 10 (quick-260918-gza): Betreff-Kuerzel und Zeile `Herkunft:` * fuer den Desktop- und den Browser-Pfad, Protokollzeile mit Kuerzel. * * `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen * Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert; * `nodemailer` kommt hier nicht vor — der Versand ist eine Attrappe von * `MailService.sendBugReport`, dessen Verhalten `mail.service.spec.ts` pinnt. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); const PNG_1x1 = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64', ); const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' }; const baseDto = { page: '/admin/users?tab=x', description: 'Knopf tut nichts', webVersion: 'v1.2.3', webChannel: 'beta', webCommit: 'abc1234', userAgent: 'UA', viewport: '1920x1080', clientTime: '2026-09-14T10:00:00.000Z', errors: ['[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}'], }; interface FakeUserRow { id: string; tenantId: string; username: string; displayName: string | null; email: string | null; role: string; } function makeFakePrisma(rows: FakeUserRow[]) { const users = new Map(rows.map((r) => [`${r.tenantId}/${r.id}`, { ...r }])); const boundCalls: { tenantId: string; method: string }[] = []; return { __boundCalls: boundCalls, __makeBoundClient(tenantId: string) { return { user: { findUnique: async ({ where, select }: any) => { boundCalls.push({ tenantId, method: 'findUnique' }); const row = users.get(`${tenantId}/${where.id}`); if (!row) return null; const out: any = {}; for (const key of Object.keys(select ?? {})) if (select[key]) out[key] = (row as any)[key]; return out; }, }, }; }, }; } function makeService(opts: { recipient?: string | null; env?: string | undefined; rows?: FakeUserRow[]; sendImpl?: () => Promise; }) { const prisma = makeFakePrisma( opts.rows ?? [ { id: 'u1', tenantId: 't1', username: 'anna', displayName: 'Anna Muster', email: 'anna@a.example.invalid', role: 'USER' }, ], ); const settingsService = { getBugReportRecipient: vi.fn(async () => (opts.recipient === undefined ? 'fehler@a.example.invalid' : opts.recipient)), }; const mailService = { sendBugReport: vi.fn(opts.sendImpl ?? (async () => undefined)), }; const configService = { get: vi.fn((key: string) => (key === 'TESSERA_BUGREPORT_TO' ? opts.env : undefined)), }; const service = new BugReportsService( settingsService as any, mailService as any, configService as any, prisma as any, ); vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined); vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); return { service, prisma, settingsService, mailService, configService }; } const pngFile = () => ({ buffer: PNG_1x1, size: PNG_1x1.length, mimetype: 'image/png' }); beforeEach(() => { vi.stubEnv('APP_VERSION', 'v9.9.9'); vi.stubEnv('APP_CHANNEL', 'live'); vi.stubEnv('APP_COMMIT', ''); }); afterEach(() => { vi.unstubAllEnvs(); vi.mocked(forTenant).mockClear(); }); describe('BugReportsService (quick-260914-m97)', () => { it('Test 1: Happy Path mit Bild — Betreff, Textrumpf mit allen Kontextzeilen aus Sitzung, Datenbankzeile und Umgebung, PNG-Anhang unveraendert, { sent: true }', async () => { const { service, mailService } = makeService({}); const result = await service.submit(sessionUser, baseDto as any, pngFile()); expect(result).toEqual({ sent: true }); expect(mailService.sendBugReport).toHaveBeenCalledTimes(1); const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[]; expect(tenantId).toBe('t1'); expect(to).toBe('fehler@a.example.invalid'); expect(report.subject).toBe('[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x'); for (const needle of [ 'Knopf tut nichts', '/admin/users?tab=x', 'Anna Muster (anna)', 'USER', 'anna@a.example.invalid', 't1', 'v1.2.3 (beta) abc1234', 'Tessera API v9.9.9 (live)', 'Herkunft: Browser — unbekannt auf unbekannt', 'UA', '1920x1080', '[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}', 'Bildschirmfoto: im Anhang', ]) { expect(report.text, `Text ohne "${needle}"`).toContain(needle); } expect(report.attachments).toHaveLength(1); expect(report.attachments[0].filename).toMatch(/^fehlermeldung-\d{8}-\d{4}\.png$/); expect(report.attachments[0].contentType).toBe('image/png'); expect(report.attachments[0].content.equals(PNG_1x1)).toBe(true); }); it('Test 2: ohne Bild -> leere Anhangsliste, Text nennt "nicht beigefügt"; ohne Beschreibung steht "(keine Beschreibung)"', async () => { const { service, mailService } = makeService({}); await service.submit(sessionUser, { ...baseDto, description: undefined } as any, undefined); const report = (mailService.sendBugReport.mock.calls[0] as any[])[2]; expect(Array.isArray(report.attachments)).toBe(true); expect(report.attachments).toHaveLength(0); expect(report.text).toContain('Bildschirmfoto: nicht beigefügt'); expect(report.text).toContain('(keine Beschreibung)'); }); it('Test 3 (Falsifizierung a): fuenf Berichte gelingen, der sechste -> 429; anderer Benutzer gleichzeitig frei; nach 10 Minuten wieder frei', async () => { vi.useFakeTimers(); try { const { service, mailService } = makeService({}); for (let i = 0; i < 5; i++) { await service.submit(sessionUser, baseDto as any, undefined); } let caught: unknown; try { await service.submit(sessionUser, baseDto as any, undefined); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(HttpException); expect((caught as HttpException).getStatus()).toBe(429); expect(mailService.sendBugReport).toHaveBeenCalledTimes(5); await expect( service.submit({ ...sessionUser, id: 'u2', username: 'bert' }, baseDto as any, undefined), ).resolves.toEqual({ sent: true }); expect(mailService.sendBugReport).toHaveBeenCalledTimes(6); vi.advanceTimersByTime(600_001); await expect(service.submit(sessionUser, baseDto as any, undefined)).resolves.toEqual({ sent: true }); expect(mailService.sendBugReport).toHaveBeenCalledTimes(7); } finally { vi.useRealTimers(); } }); it('Test 4 (Falsifizierung b): Datei ohne PNG-Kopf -> 400, sendBugReport nie gerufen; auch ein Buffer aus nur 7 PNG-Bytes -> 400', async () => { const { service, mailService } = makeService({}); const fake = Buffer.from('nicht png, aber lang genug'); await expect( service.submit(sessionUser, baseDto as any, { buffer: fake, size: fake.length, mimetype: 'image/png' }), ).rejects.toBeInstanceOf(BadRequestException); const short = PNG_1x1.subarray(0, 7); await expect( service.submit(sessionUser, baseDto as any, { buffer: short, size: short.length, mimetype: 'image/png' }), ).rejects.toBeInstanceOf(BadRequestException); expect(mailService.sendBugReport).not.toHaveBeenCalled(); }); it('Test 5 (Falsifizierung c): weder Feld noch Variable -> 409 mit Hinweis auf "Fehlermeldungen an"; Leerstring in der Variable zaehlt als ungesetzt; nie versendet', async () => { const a = makeService({ recipient: null, env: undefined }); let caught: unknown; try { await a.service.submit(sessionUser, baseDto as any, pngFile()); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(ConflictException); expect((caught as ConflictException).message).toContain('Fehlermeldungen an'); expect(a.mailService.sendBugReport).not.toHaveBeenCalled(); const b = makeService({ recipient: null, env: '' }); await expect(b.service.submit(sessionUser, baseDto as any, pngFile())).rejects.toBeInstanceOf(ConflictException); expect(b.mailService.sendBugReport).not.toHaveBeenCalled(); }); it('Test 6: Umgebungs-Rueckfall TESSERA_BUGREPORT_TO greift ohne Feld; mit Feld UND Variable gewinnt das Feld', async () => { const a = makeService({ recipient: null, env: 'ops@a.example.invalid' }); await a.service.submit(sessionUser, baseDto as any, undefined); expect((a.mailService.sendBugReport.mock.calls[0] as any[])[1]).toBe('ops@a.example.invalid'); const b = makeService({ recipient: 'fehler@a.example.invalid', env: 'ops@a.example.invalid' }); await b.service.submit(sessionUser, baseDto as any, undefined); expect((b.mailService.sendBugReport.mock.calls[0] as any[])[1]).toBe('fehler@a.example.invalid'); }); it('Test 7: Versandfehler -> 502 "E-Mail konnte nicht gesendet werden"; der Versuch zaehlt in der Drossel, sperrt aber nicht', async () => { let calls = 0; const { service, mailService } = makeService({ sendImpl: async () => { calls += 1; if (calls === 1) throw new Error('ECONNREFUSED'); }, }); let caught: unknown; try { await service.submit(sessionUser, baseDto as any, pngFile()); } catch (e) { caught = e; } expect(caught).toBeInstanceOf(BadGatewayException); expect((caught as BadGatewayException).message).toContain('E-Mail konnte nicht gesendet werden'); await expect(service.submit(sessionUser, baseDto as any, pngFile())).resolves.toEqual({ sent: true }); expect(mailService.sendBugReport).toHaveBeenCalledTimes(2); }); it('Test 8 (Falsifizierung d): Fremdfelder tenantId/userId im Rumpf aendern nichts — Empfaenger, forTenant und Versand laufen mit der Sitzungskennung t1, die fremde Zeile taucht nicht auf', async () => { const { service, settingsService, mailService } = makeService({ rows: [ { id: 'u1', tenantId: 't1', username: 'anna', displayName: 'Anna Muster', email: 'anna@a.example.invalid', role: 'USER' }, { id: 'u1', tenantId: 'fremd', username: 'anna', displayName: 'Fremde Anna', email: 'fremd@x.invalid', role: 'ADMIN' }, { id: 'u-fremd', tenantId: 'fremd', username: 'eindringling', displayName: 'Eindringling', email: 'e@x.invalid', role: 'ADMIN' }, ], }); await service.submit( sessionUser, { ...baseDto, tenantId: 'fremd', userId: 'u-fremd' } as any, undefined, ); expect(settingsService.getBugReportRecipient).toHaveBeenCalledWith('t1'); expect(vi.mocked(forTenant).mock.calls.every((c) => c[1] === 't1')).toBe(true); expect(vi.mocked(forTenant).mock.calls.length).toBeGreaterThan(0); const [tenantId, , report] = mailService.sendBugReport.mock.calls[0] as any[]; expect(tenantId).toBe('t1'); expect(report.text).toContain('Anna Muster (anna)'); expect(report.text).not.toContain('Fremde Anna'); expect(report.text).not.toContain('Eindringling'); expect(report.text).not.toContain('fremd@x.invalid'); }); it('Test 9 (quick-260918-gza): Desktop/Windows — Betreff-Kuerzel, Zeile Herkunft VOR Browser:, Protokollzeile mit Kuerzel', async () => { const { service, mailService } = makeService({}); const edgeUa = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52'; await service.submit( sessionUser, { ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa, } as any, undefined, ); const report = (mailService.sendBugReport.mock.calls[0] as any[])[2]; expect(report.subject.startsWith('[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - ')).toBe(true); expect(report.text).toContain('Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64'); expect(report.text).toContain('Browser: Mozilla/5.0 (Windows NT 10.0'); expect(report.text).toContain('Fenster: 1920x1080'); expect(report.text.indexOf('Herkunft:')).toBeLessThan(report.text.indexOf('Browser:')); const { service: freshService, mailService: freshMail } = makeService({}); freshMail.sendBugReport.mockClear(); (freshService as any).logger.log.mockClear(); await freshService.submit( sessionUser, { ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa } as any, undefined, ); expect((freshService as any).logger.log).toHaveBeenCalledTimes(1); expect((freshService as any).logger.log.mock.calls[0][0]).toContain('[Desktop/Windows]'); }); it('Test 10 (quick-260918-gza): Browser mit echtem User-Agent — Betreff und Zeile Herkunft nennen Browser und Version', async () => { const { service, mailService } = makeService({}); const chromeUa = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; await service.submit(sessionUser, { ...baseDto, clientKind: 'browser', userAgent: chromeUa } as any, undefined); const report = (mailService.sendBugReport.mock.calls[0] as any[])[2]; expect(report.subject).toContain('[Browser]'); expect(report.text).toContain('Herkunft: Browser — Chrome 129 auf Windows'); }); });