716947228e
WebView2 (Windows) sieht im User-Agent aus wie Edge, WebKitGTK (Linux) wie Safari — im Postfach war eine Client-Meldung von einer Browser-Meldung nicht zu unterscheiden. Neuer reiner Helfer origin.ts leitet aus vier optionalen DTO-Feldern (Desktop-App) bzw. dem User-Agent (Browser) ein Betreff-Kuerzel und eine Zeile "Herkunft: ..." ab; rein informativ, laengenbegrenzt, nichts wird gespeichert (T-GZA-01). Browser-Pfad ist damit Ende-zu-Ende fertig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
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<void>;
|
|
}) {
|
|
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');
|
|
});
|
|
});
|