feat(quick-260914-m97): Fehlermeldungen per E-Mail — Empfaenger in SmtpConfig (Migration), MailService-Anhaenge, Modul bug-reports mit Drossel, PNG-Pruefung und Mandant aus der Sitzung

- SmtpConfig.bugReportRecipient (nullable, additive Migration 20260914170000), DTO @IsOptional @IsEmail, SAFE_SELECT, getBugReportRecipient gebunden
- MailService: Versandkern deliver (wirft, Anhaenge), sendViaTenantTransport bleibt verschluckender Mantel (T-02-12), sendBugReport laesst Fehler durch
- POST /bug-reports: Multipart 4 MiB je Route, alle angemeldeten Rollen, Drossel 5/10 min -> 429, PNG-Signatur -> 400, kein Empfaenger -> 409, Versandfehler -> 502, eine Protokollzeile
- Falsifizierungen (a)-(d) als Specs; @Expose() im DTO, damit errors auch bei fehlendem Feld zu [] wird
- Doku-Zeile fuer rls-access-inventory, TESSERA_BUGREPORT_TO in docker-compose.prod.yml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
2026-09-14 16:45:09 +02:00
parent 17a7e5ef9b
commit 54121c1721
16 changed files with 919 additions and 12 deletions
+2
View File
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { AuthModule } from './auth/auth.module';
import { BugReportsModule } from './bug-reports/bug-reports.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor';
@@ -47,6 +48,7 @@ import { UserModule } from './user/user.module';
DkvModule,
FavoritesModule,
TendersModule,
BugReportsModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,66 @@
import 'reflect-metadata';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { BugReportsController } from './bug-reports.controller';
import { BugReportDto } from './dto/bug-report.dto';
/**
* BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
*
* Drei Tests an der Grenze Browser -> API:
* 1. die globale Pipe (`whitelist: true, transform: true`, wie in
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
* normalisiert `errors` (multer/append-field liefert EIN Feld als
* String, mehrere als Array, keins als undefined);
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
* `@Roles`-Metadatum, Pfad `bug-reports`.
*/
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const meta = { type: 'body' as const, metatype: BugReportDto };
const baseBody = {
page: '/x',
webVersion: 'v1',
webChannel: 'beta',
webCommit: '',
userAgent: 'UA',
viewport: '1x1',
clientTime: 't',
};
describe('BugReportsController (quick-260914-m97)', () => {
it('Test 1: whitelist entfernt tenantId; errors wird aus String/undefined/Array normalisiert', async () => {
const single = (await pipe.transform({ ...baseBody, errors: 'einzeln', tenantId: 'fremd' }, meta)) as any;
expect(Object.prototype.hasOwnProperty.call(single, 'tenantId')).toBe(false);
expect(single.errors).toEqual(['einzeln']);
const none = (await pipe.transform({ ...baseBody }, meta)) as any;
expect(none.errors).toEqual([]);
const many = (await pipe.transform({ ...baseBody, errors: ['a', 'b'] }, meta)) as any;
expect(many.errors).toEqual(['a', 'b']);
});
it('Test 2: Grenzen — 31 Eintraege oder 4001 Zeichen -> BadRequestException; 30 Eintraege und 4000 Zeichen gelingen', async () => {
const thirtyOne = Array.from({ length: 31 }, (_, i) => `e${i}`);
await expect(pipe.transform({ ...baseBody, errors: thirtyOne }, meta)).rejects.toThrow(BadRequestException);
await expect(
pipe.transform({ ...baseBody, description: 'x'.repeat(4001) }, meta),
).rejects.toThrow(BadRequestException);
const ok = (await pipe.transform(
{ ...baseBody, errors: thirtyOne.slice(0, 30), description: 'x'.repeat(4000) },
meta,
)) as any;
expect(ok.errors).toHaveLength(30);
expect(ok.description).toHaveLength(4000);
});
it('Test 3: nur angemeldet — kein @Roles-Metadatum auf submit, Controller-Pfad bug-reports', () => {
expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
});
});
@@ -0,0 +1,39 @@
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { BugReportsService } from './bug-reports.service';
import { BugReportDto } from './dto/bug-report.dto';
/**
* POST /bug-reports — Fehler-melden-Knopf (quick-260914-m97).
*
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
* (Muster `user.controller.ts`, Selbstbedienungs-Avatar — `RolesGuard`
* laesst bei leerer Rollenliste durch, der globale `JwtAuthGuard` verlangt
* weiterhin eine Sitzung).
*
* Multipart mit Groessenlimit JE ROUTE (T-M97-03): `FileInterceptor` nimmt
* genau eine Datei `screenshot` bis 4 MiB entgegen; multers
* `LIMIT_FILE_SIZE` wird von Nest auf 413 abgebildet. `main.ts` bleibt
* ohne globales Body-Limit.
*
* Mandant und Benutzer kommen NUR aus dem Sitzungsnachweis
* (`@CurrentUser()`), nie aus dem Rumpf (T-M97-06) — das DTO kennt keine
* solchen Felder, die globale Pipe entfernt Fremdfelder.
*/
@Controller('bug-reports')
export class BugReportsController {
constructor(private readonly service: BugReportsService) {}
@Post()
@UseInterceptors(
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
)
async submit(
@CurrentUser() user: any,
@Body() dto: BugReportDto,
@UploadedFile() file?: any,
) {
return this.service.submit(user, dto, file);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { SettingsModule } from '../settings/settings.module';
import { BugReportsController } from './bug-reports.controller';
import { BugReportsService } from './bug-reports.service';
/**
* BugReportsModule — Fehler-melden-Knopf (quick-260914-m97).
*
* Braucht `SettingsService` (Empfaenger des Mandanten) und `MailService`
* (Versand mit Anhang ueber den Transport des Mandanten); `PrismaModule`
* ist global, `ConfigModule` ebenfalls.
*/
@Module({
imports: [SettingsModule, MailModule],
controllers: [BugReportsController],
providers: [BugReportsService],
})
export class BugReportsModule {}
@@ -0,0 +1,294 @@
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).
*
* Acht 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.
*
* `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] 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)',
'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');
});
});
@@ -0,0 +1,178 @@
import {
BadGatewayException,
BadRequestException,
ConflictException,
HttpException,
HttpStatus,
Injectable,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { formatAppVersionLine } from '../health/app-version';
import { MailService } from '../mail/mail.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { BugReportDto } from './dto/bug-report.dto';
/**
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
*
* Zweck: ein angemeldeter Anwender schickt aus der Kopfzeile ein
* Bildschirmfoto der aktuellen Seite samt Beschreibung und Kontext; der
* Dienst baut daraus EINE E-Mail mit PNG-Anhang und verschickt sie ueber
* den Transport des Sitzungs-Mandanten an das eingestellte Postfach
* (`SmtpConfig.bugReportRecipient`, Rueckfall `TESSERA_BUGREPORT_TO`).
*
* Warum Multipart (Controller) statt JSON mit Base64: das Groessenlimit
* gilt dann NUR fuer diese Route (`FileInterceptor`, 4 MiB), `main.ts`
* bleibt ohne globales Body-Limit — ein globales JSON-Limit waere eine
* DoS-Flaeche fuer jede Route inklusive `/auth/login` (T-M97-03).
*
* Warum kein Speichern: die Meldung ist eine E-Mail an den Betreiber,
* nichts weiter. Tessera legt keine Tabelle dafuer an — kein Bild, keine
* Beschreibung landet in der Datenbank oder im Protokoll (T-M97-01).
*
* Drossel-Semantik: hoechstens 5 Berichte je Benutzer je 10 Minuten,
* gezaehlt im Speicher dieses Prozesses (keine Drossel-Bibliothek im
* Projekt). Ein Versuch zaehlt auch dann, wenn der Versand danach
* scheitert — Fehlversuche sperren nicht zusaetzlich, sie zaehlen nur.
*
* Sicherheit: T-M97-03 (Limit je Route + Drossel), T-M97-04
* (PNG-Signatur, fester Dateiname und Typ), T-M97-06 (Mandant und
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
* einen gebundenen Klienten — Zeile in
* docs/mandantentrennung-zugriffsklassifikation.md).
*/
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface SessionUser {
id: string;
username: string;
role: string;
tenantId: string;
}
interface UploadedPng {
buffer: Buffer;
size: number;
mimetype?: string;
}
@Injectable()
export class BugReportsService {
private readonly logger = new Logger(BugReportsService.name);
/** Zeitstempel der letzten Berichte je Benutzerkennung (Drossel). */
private readonly recent = new Map<string, number[]>();
constructor(
private readonly settingsService: SettingsService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async submit(user: SessionUser, dto: BugReportDto, file?: UploadedPng): Promise<{ sent: true }> {
// (1) Drossel: alte Zeitstempel verwerfen, Grenze pruefen, Versuch zaehlen.
const now = Date.now();
const stamps = (this.recent.get(user.id) ?? []).filter((t) => now - t < WINDOW_MS);
if (stamps.length >= MAX_PER_WINDOW) {
this.recent.set(user.id, stamps);
throw new HttpException(
'Zu viele Fehlermeldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
stamps.push(now);
this.recent.set(user.id, stamps);
// (2) Bild pruefen: nur echte PNG-Dateien (T-M97-04).
if (file) {
if (file.buffer.length < PNG_SIGNATURE.length || !file.buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new BadRequestException('Das Bildschirmfoto ist keine gültige PNG-Datei.');
}
}
// (3) Empfaenger: Feld des Mandanten, sonst Umgebungs-Rueckfall (Leerstring = ungesetzt).
const to =
(await this.settingsService.getBugReportRecipient(user.tenantId)) ||
(this.configService.get<string>('TESSERA_BUGREPORT_TO') || '').trim() ||
null;
if (!to) {
throw new ConflictException(
'Für Fehlermeldungen ist noch kein Postfach eingerichtet. Ein Administrator legt es unter Administrator → SMTP im Feld „Fehlermeldungen an“ fest.',
);
}
// (4) Benutzerzeile: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten
// (T-M97-06; Zeile in docs/mandantentrennung-zugriffsklassifikation.md).
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
const row = await tenantPrisma.user.findUnique({
where: { id: user.id },
select: { username: true, displayName: true, email: true, role: true },
});
const username: string = row?.username ?? user.username;
const displayName: string = row?.displayName || username;
const email: string = row?.email ?? '-';
const role: string = row?.role ?? user.role;
// (5) Betreff
const pageShort = dto.page.slice(0, 120);
const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
// (6) Text
const bytes = file ? file.buffer.length : 0;
const errors = dto.errors ?? [];
const text = [
'Ein Anwender hat über den Knopf „Fehler melden“ eine Meldung geschickt.',
'',
'Was ist passiert?',
dto.description && dto.description.trim().length > 0 ? dto.description : '(keine Beschreibung)',
'',
`Seite: ${dto.page}`,
`Zeitpunkt (Server): ${new Date().toISOString()}`,
`Zeitpunkt (Browser): ${dto.clientTime}`,
`Benutzer: ${displayName} (${username}), Rolle ${role}, E-Mail ${email}`,
`Mandant: ${user.tenantId}`,
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
`API: ${formatAppVersionLine()}`,
`Browser: ${dto.userAgent}`,
`Fenster: ${dto.viewport}`,
'',
`Letzte Fehlermeldungen im Browser (${errors.length}):`,
...(errors.length > 0 ? errors.map((e) => `- ${e}`) : ['- keine']),
'',
file ? `Bildschirmfoto: im Anhang (${bytes} Bytes)` : 'Bildschirmfoto: nicht beigefügt',
].join('\n');
// (7) Anhang: fester Name und Typ — der Client bestimmt beides nicht (T-M97-04).
const attachments = file
? [{ filename: `fehlermeldung-${formatStamp(new Date())}.png`, content: file.buffer, contentType: 'image/png' }]
: [];
// (8) Versand: Fehler sichtbar machen (502), nie still verschlucken.
try {
await this.mailService.sendBugReport(user.tenantId, to, { subject, text, attachments });
} catch (error) {
this.logger.error('Bug report mail failed', error instanceof Error ? error.stack : String(error));
throw new BadGatewayException(
'E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.',
);
}
// (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
this.logger.log(
`Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
);
return { sent: true };
}
}
/** `yyyymmdd-hhmm` in UTC fuer den Anhangsnamen. */
function formatStamp(d: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, '0');
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}`;
}
@@ -0,0 +1,80 @@
import { Expose, Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
/**
* Rumpf von `POST /bug-reports` (quick-260914-m97, Fehler-melden-Knopf).
*
* Die Felder kommen als `multipart/form-data` (das Bild liegt als Datei
* `screenshot` daneben, siehe Controller) — multer liefert deshalb alle
* Textfelder als Strings. Ein EINZELNES wiederholtes Feld `errors` kommt
* als String, mehrere als Array, keines als undefined (append-field,
* gemessen zur Planungszeit); ohne die Normalisierung unten wuerde
* `@IsArray()` bei genau einer Fehlermeldung scheitern.
*
* Mandant und Benutzer stehen BEWUSST NICHT in diesem DTO (T-M97-06): der
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
*/
export class BugReportDto {
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
/** Pfad plus Suchteil der Seite, ohne Host. */
@IsString()
@MaxLength(2000)
page!: string;
@IsString()
@MaxLength(100)
webVersion!: string;
@IsString()
@MaxLength(20)
webChannel!: string;
/** Kurzer Commit-Hash; Leerstring ist erlaubt (lokaler Bau ohne Stempel). */
@IsString()
@MaxLength(64)
webCommit!: string;
@IsString()
@MaxLength(1000)
userAgent!: string;
/** `<Breite>x<Hoehe>` des Browserfensters. */
@IsString()
@MaxLength(50)
viewport!: string;
/** ISO-Zeitstempel des Browsers zum Sendezeitpunkt. */
@IsString()
@MaxLength(50)
clientTime!: string;
/**
* Die letzten Fehlermeldungen aus dem Browser-Ringpuffer, je
* `[<ISO>] <Art>: <Meldung>`. `@Expose()` sorgt dafuer, dass die
* Normalisierung auch laeuft, wenn das Feld im Rumpf GANZ fehlt
* (class-transformer ruft `@Transform` sonst nur fuer vorhandene
* Schluessel auf — gemessen: ohne `@Expose()` scheitert `@IsArray()`).
*/
@Expose()
@Transform(({ value }) =>
value === undefined || value === null ? [] : Array.isArray(value) ? value : [value],
)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@MaxLength(1000, { each: true })
errors!: string[];
}
+52 -1
View File
@@ -18,7 +18,14 @@ import { MailService } from './mail.service';
* protokolliert, `close()` trotzdem gerufen.
*
* `nodemailer` wird per `vi.mock` ersetzt (wie in settings.service.spec.ts)
* — kein echter Transport, lokal gibt es keinen `mailhog`.
* — kein echter Transport, der lokale `mailhog` aus docker-compose.dev.yml
* ist nur fuer den Browser-Check gedacht.
*
* Erweitert in quick-260914-m97 (Fehler-melden-Knopf): Tests 5 und 6 pinnen
* `sendBugReport` — Anhaenge werden 1:1 an `sendMail` durchgereicht, und
* Fehler gehen bewusst NACH AUSSEN (der Anwender soll wissen, ob sein
* Bericht ankam), waehrend `sendPasswordResetEmail` weiterhin verschluckt
* (T-02-12 unveraendert, Gegenprobe im selben Test).
*/
let mockSendMail = vi.fn(async (_mail: unknown) => ({}));
@@ -196,4 +203,48 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
expect(JSON.stringify(errorSpy.mock.calls[0])).not.toContain('geheim-a');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 5 (260914-m97): sendBugReport reicht to/subject/text und den PNG-Anhang unveraendert an sendMail durch, from = fromAddress des Mandanten, close() gerufen', async () => {
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const png = Buffer.from([1, 2, 3]);
await service.sendBugReport('t1', 'fehler@a.example.invalid', {
subject: 'S',
text: 'T',
attachments: [{ filename: 'x.png', content: png, contentType: 'image/png' }],
});
expect(mockSendMail).toHaveBeenCalledTimes(1);
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.to).toBe('fehler@a.example.invalid');
expect(sent.subject).toBe('S');
expect(sent.text).toBe('T');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('x.png');
expect(sent.attachments[0].contentType).toBe('image/png');
expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true);
expect((sent.attachments[0].content as Buffer).equals(png)).toBe(true);
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('Test 6 (260914-m97): sendBugReport laesst Transportfehler DURCH (rejects), close() trotzdem; Gegenprobe: sendPasswordResetEmail verschluckt denselben Fehler weiterhin (T-02-12)', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED smtp-a.example.invalid');
});
const settings = makeFakeSettings({ t1: configA });
const service = new MailService(settings as any, makeFakeConfig({}) as any);
const errorSpy = vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
await expect(
service.sendBugReport('t1', 'fehler@a.example.invalid', { subject: 'S', text: 'T', attachments: [] }),
).rejects.toThrow('ECONNREFUSED');
expect(mockClose).toHaveBeenCalledTimes(1);
await expect(
service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-1', 't1'),
).resolves.toBeUndefined();
expect(mockClose).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalled();
});
});
+63 -11
View File
@@ -37,11 +37,36 @@ import { SettingsService } from '../settings/settings.service';
* der Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration).
*
* Sicherheit: das entschluesselte Kennwort existiert nur im Rumpf von
* `resolveTransport`/`sendViaTenantTransport` und wird nie protokolliert
* `resolveTransport`/`deliver` und wird nie protokolliert
* (T-07-10/T-07-11); Protokollzeilen nennen nur Quelle (tenant/env) und
* Empfaenger.
*
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
*/
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
export interface OutgoingAttachment {
filename: string;
content: Buffer;
contentType: string;
}
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
export interface OutgoingMail {
to: string;
subject: string;
text: string;
html?: string;
attachments?: OutgoingAttachment[];
}
/** Was `BugReportsService` liefert — Empfaenger und Mandant kommen getrennt. */
export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments'>;
interface ResolvedTransport {
source: 'tenant' | 'env';
options: nodemailer.TransportOptions & Record<string, unknown>;
@@ -128,15 +153,12 @@ export class MailService {
}
/**
* Der eine Versandpfad: Transport je Versand aus `resolveTransport`,
* Fehler verschluckt und protokolliert (T-02-12), `close()` im `finally`
* (WR-01 — keine offenen Verbindungen).
* Der eine Versandkern: Transport je Versand aus `resolveTransport`,
* `sendMail` mit optionalem HTML und Anhaengen, `close()` im `finally`
* (WR-01 — keine offenen Verbindungen). WIRFT bei Transportfehlern —
* ob der Fehler nach aussen geht, entscheidet der Aufrufer.
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
private async deliver(tenantId: string, mail: OutgoingMail, kind: string): Promise<void> {
let transport: nodemailer.Transporter | null = null;
try {
const resolved = await this.resolveTransport(tenantId);
@@ -146,19 +168,49 @@ export class MailService {
to: mail.to,
subject: mail.subject,
text: mail.text,
...(mail.html !== undefined ? { html: mail.html } : {}),
...(mail.attachments !== undefined ? { attachments: mail.attachments } : {}),
});
this.logger.log(`${kind} email sent to ${mail.to} (transport: ${resolved.source})`);
} finally {
transport?.close();
}
}
/**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
* bleibt fuer genau diese beiden Wege bestehen).
*/
private async sendViaTenantTransport(
tenantId: string,
mail: { to: string; subject: string; text: string },
kind: string,
): Promise<void> {
try {
await this.deliver(tenantId, mail, kind);
} catch (error) {
// Log but don't throw -- caller returns 200 regardless (T-02-12)
this.logger.error(
`Failed to send ${kind} email to ${mail.to}`,
error instanceof Error ? error.stack : String(error),
);
} finally {
transport?.close();
}
}
/**
* Fehlermeldung eines Anwenders (quick-260914-m97) mit PNG-Anhang an das
* eingestellte Postfach des Mandanten. Fehler gehen BEWUSST nach aussen —
* anders als bei T-02-12: hier gibt es nichts zu verbergen (kein
* Anmeldeweg, kein Enumerationsrisiko), und der Anwender soll wissen, ob
* sein Bericht angekommen ist. `BugReportsService` uebersetzt den Fehler
* in eine 502-Antwort.
*/
async sendBugReport(tenantId: string, to: string, report: BugReportMail): Promise<void> {
await this.deliver(tenantId, { to, ...report }, 'Bug report');
}
/**
* Send a password reset email with a time-limited token link.
* T-02-12: The caller always returns 200 regardless of whether this succeeds
@@ -48,4 +48,15 @@ export class SmtpConfigDto {
@IsOptional()
@IsEmail()
testTo?: string;
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97). Optional:
* fehlt das Feld im PUT, bleibt der gespeicherte Wert; `null` loescht ihn.
* Absicht (T-M97-05): nur ein Administrator dieses Mandanten kann das
* Ziel aller Fehlermeldungen seines Mandanten setzen — der Weg fuehrt
* ausschliesslich ueber `PUT /settings/smtp` mit `@Roles(ADMIN, SUPER_ADMIN)`.
*/
@IsOptional()
@IsEmail()
bugReportRecipient?: string | null;
}
@@ -40,6 +40,7 @@ interface FakeSmtpRow {
username: string | null;
encryptedPassword: string | null;
fromAddress: string;
bugReportRecipient?: string | null;
createdAt?: Date;
updatedAt?: Date;
}
@@ -483,4 +484,71 @@ describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => {
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('bugReportRecipient — Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)', () => {
const rowWithRecipient: FakeSmtpRow = {
id: 'smtp-a',
tenantId: 't1',
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
username: 'user-a',
encryptedPassword: 'enc(geheim)',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
};
it('Test A: getBugReportRecipient liefert den gespeicherten Wert ueber GENAU EINEN gebundenen findUnique; fremder Mandant oder null-Feld -> null', async () => {
const prisma = makeFakePrisma([
rowWithRecipient,
{ ...rowWithRecipient, id: 'smtp-c', tenantId: 't3', bugReportRecipient: null },
]);
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
vi.mocked(forTenant).mockClear();
const found = await service.getBugReportRecipient('t1');
expect(found).toBe('fehler@a.example.invalid');
expectBoundCall(prisma, 't1', 'findUnique');
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
expect(await service.getBugReportRecipient('t2')).toBeNull();
expect(await service.getBugReportRecipient('t3')).toBeNull();
});
it('Test B: saveSmtpConfig mit bugReportRecipient speichert den Wert; Rueckgabe traegt bugReportRecipient und KEIN encryptedPassword', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const result = await service.saveSmtpConfig('t1', {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
bugReportRecipient: 'fehler@a.example.invalid',
} as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).bugReportRecipient).toBe('fehler@a.example.invalid');
expect((result as any).encryptedPassword).toBeUndefined();
});
it('Test C: DTO OHNE das Feld bewahrt den gespeicherten Wert, DTO mit null loescht ihn', async () => {
const prisma = makeFakePrisma();
const service = new SettingsService(prisma as any, makeFakeCrypto() as any);
const base = {
host: 'smtp-a.example.invalid',
port: 587,
encryption: 'starttls',
fromAddress: 'a@example.invalid',
};
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: 'fehler@a.example.invalid' } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBe('fehler@a.example.invalid');
await service.saveSmtpConfig('t1', { ...base, bugReportRecipient: null } as any);
expect(prisma.__configs.get('t1').bugReportRecipient).toBeNull();
});
});
});
+23
View File
@@ -19,6 +19,7 @@ const SMTP_SAFE_SELECT = {
username: true,
// encryptedPassword: NEVER included — T-07-07
fromAddress: true,
bugReportRecipient: true, // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97)
createdAt: true,
updatedAt: true,
} as const;
@@ -77,6 +78,10 @@ export class SettingsService {
username: dto.username ?? null,
fromAddress: dto.fromAddress,
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
// quick-260914-m97: fehlendes Feld = bewahren, null/leer = loeschen
...(dto.bugReportRecipient !== undefined
? { bugReportRecipient: dto.bugReportRecipient || null }
: {}),
};
const result = await tenantPrisma.smtpConfig.upsert({
@@ -89,6 +94,24 @@ export class SettingsService {
return result;
}
/**
* Postfach fuer den Fehler-melden-Knopf (quick-260914-m97): der Wert aus
* `SmtpConfig.bugReportRecipient` des Mandanten oder `null`, wenn keine
* Zeile existiert oder das Feld leer ist. Verwender: `BugReportsService`
* (der dort den Umgebungs-Rueckfall `TESSERA_BUGREPORT_TO` anhaengt).
*
* Mandantengebunden: EIN Klient `tenantPrisma`, `findUnique` mit
* schmalem `select` — das verschluesselte Kennwort wird hier nie geladen.
*/
async getBugReportRecipient(tenantId: string): Promise<string | null> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const row = await tenantPrisma.smtpConfig.findUnique({
where: { tenantId },
select: { bugReportRecipient: true },
});
return row?.bugReportRecipient ?? null;
}
/**
* Internal: Get the decrypted SMTP config for a tenant.
* Used by DkvMailService/TenderMailService — and seit 260914-eym auch von