From 54121c1721fcfcaec7de10573b39403472db3c19 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 14 Sep 2026 16:45:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(quick-260914-m97):=20Fehlermeldungen=20per?= =?UTF-8?q?=20E-Mail=20=E2=80=94=20Empfaenger=20in=20SmtpConfig=20(Migrati?= =?UTF-8?q?on),=20MailService-Anhaenge,=20Modul=20bug-reports=20mit=20Dros?= =?UTF-8?q?sel,=20PNG-Pruefung=20und=20Mandant=20aus=20der=20Sitzung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY --- .../migration.sql | 18 ++ apps/api/prisma/schema.prisma | 1 + apps/api/src/app.module.ts | 2 + .../bug-reports.controller.spec.ts | 66 ++++ .../src/bug-reports/bug-reports.controller.ts | 39 +++ .../api/src/bug-reports/bug-reports.module.ts | 19 ++ .../bug-reports/bug-reports.service.spec.ts | 294 ++++++++++++++++++ .../src/bug-reports/bug-reports.service.ts | 178 +++++++++++ .../api/src/bug-reports/dto/bug-report.dto.ts | 80 +++++ apps/api/src/mail/mail.service.spec.ts | 53 +++- apps/api/src/mail/mail.service.ts | 74 ++++- apps/api/src/settings/dto/smtp-config.dto.ts | 11 + .../api/src/settings/settings.service.spec.ts | 68 ++++ apps/api/src/settings/settings.service.ts | 23 ++ docker-compose.prod.yml | 3 + ...andantentrennung-zugriffsklassifikation.md | 2 + 16 files changed, 919 insertions(+), 12 deletions(-) create mode 100644 apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql create mode 100644 apps/api/src/bug-reports/bug-reports.controller.spec.ts create mode 100644 apps/api/src/bug-reports/bug-reports.controller.ts create mode 100644 apps/api/src/bug-reports/bug-reports.module.ts create mode 100644 apps/api/src/bug-reports/bug-reports.service.spec.ts create mode 100644 apps/api/src/bug-reports/bug-reports.service.ts create mode 100644 apps/api/src/bug-reports/dto/bug-report.dto.ts diff --git a/apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql b/apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql new file mode 100644 index 0000000..b3f17e7 --- /dev/null +++ b/apps/api/prisma/migrations/20260914170000_smtp_config_bug_report_recipient/migration.sql @@ -0,0 +1,18 @@ +-- Fehler-melden-Knopf (quick-260914-m97): Anwender schicken aus der +-- Kopfzeile ein Bildschirmfoto der aktuellen Seite samt Beschreibung und +-- Kontext als E-Mail an ein Postfach, das der Administrator unter +-- Administrator -> SMTP im Feld "Fehlermeldungen an" festlegt. +-- +-- Warum in "SmtpConfig" und nicht in einer eigenen Tabelle: der Empfaenger +-- gehoert zum Mailversand des Mandanten -- ohne gespeicherte +-- SMTP-Einstellungen gibt es ohnehin keinen Transport, ueber den die +-- Meldung rausgehen koennte. Die bestehende Zeilenschutz-Regel +-- "tenant_isolation_policy" auf "SmtpConfig" (Migration 20260909140000) +-- gilt fuer die neue Spalte automatisch mit. Eine Systemleseregel ist +-- nicht noetig: die Route POST /bug-reports laeuft immer mit einem +-- angemeldeten Benutzer, also mit gesetztem Mandantenkontext. +-- +-- Additiv und nullbar: Bestandszeilen bleiben unangetastet (Spalte ist +-- fuer sie NULL = kein Postfach, der Betriebs-Rueckfall TESSERA_BUGREPORT_TO +-- greift). Keine bestehende Migration wurde angefasst. +ALTER TABLE "SmtpConfig" ADD COLUMN "bugReportRecipient" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 118ee07..1278451 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -339,6 +339,7 @@ model SmtpConfig { username String? encryptedPassword String? // AES-256-GCM via CalendarCryptoService fromAddress String + bugReportRecipient String? // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97); leer = Rueckfall TESSERA_BUGREPORT_TO createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9b845ab..fbf3756 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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() diff --git a/apps/api/src/bug-reports/bug-reports.controller.spec.ts b/apps/api/src/bug-reports/bug-reports.controller.spec.ts new file mode 100644 index 0000000..164f0fb --- /dev/null +++ b/apps/api/src/bug-reports/bug-reports.controller.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/bug-reports/bug-reports.controller.ts b/apps/api/src/bug-reports/bug-reports.controller.ts new file mode 100644 index 0000000..a7b0df4 --- /dev/null +++ b/apps/api/src/bug-reports/bug-reports.controller.ts @@ -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); + } +} diff --git a/apps/api/src/bug-reports/bug-reports.module.ts b/apps/api/src/bug-reports/bug-reports.module.ts new file mode 100644 index 0000000..7bf7137 --- /dev/null +++ b/apps/api/src/bug-reports/bug-reports.module.ts @@ -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 {} diff --git a/apps/api/src/bug-reports/bug-reports.service.spec.ts b/apps/api/src/bug-reports/bug-reports.service.spec.ts new file mode 100644 index 0000000..781c3a0 --- /dev/null +++ b/apps/api/src/bug-reports/bug-reports.service.spec.ts @@ -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; +}) { + 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'); + }); +}); diff --git a/apps/api/src/bug-reports/bug-reports.service.ts b/apps/api/src/bug-reports/bug-reports.service.ts new file mode 100644 index 0000000..999d13e --- /dev/null +++ b/apps/api/src/bug-reports/bug-reports.service.ts @@ -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(); + + 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('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())}`; +} diff --git a/apps/api/src/bug-reports/dto/bug-report.dto.ts b/apps/api/src/bug-reports/dto/bug-report.dto.ts new file mode 100644 index 0000000..83e8875 --- /dev/null +++ b/apps/api/src/bug-reports/dto/bug-report.dto.ts @@ -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; + + /** `x` 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 + * `[] : `. `@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[]; +} diff --git a/apps/api/src/mail/mail.service.spec.ts b/apps/api/src/mail/mail.service.spec.ts index e54b248..063ac2b 100644 --- a/apps/api/src/mail/mail.service.spec.ts +++ b/apps/api/src/mail/mail.service.spec.ts @@ -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(); + }); }); diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index a41d0d4..e7e9764 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -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; + interface ResolvedTransport { source: 'tenant' | 'env'; options: nodemailer.TransportOptions & Record; @@ -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 { + private async deliver(tenantId: string, mail: OutgoingMail, kind: string): Promise { 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 { + 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 { + 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 diff --git a/apps/api/src/settings/dto/smtp-config.dto.ts b/apps/api/src/settings/dto/smtp-config.dto.ts index d1658d4..6408319 100644 --- a/apps/api/src/settings/dto/smtp-config.dto.ts +++ b/apps/api/src/settings/dto/smtp-config.dto.ts @@ -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; } diff --git a/apps/api/src/settings/settings.service.spec.ts b/apps/api/src/settings/settings.service.spec.ts index f91871f..d6c587a 100644 --- a/apps/api/src/settings/settings.service.spec.ts +++ b/apps/api/src/settings/settings.service.spec.ts @@ -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(); + }); + }); }); diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 0765de5..27971fd 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -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 { + 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 394ec28..32a2036 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -47,6 +47,9 @@ services: TESSERA_SMTP_USER: ${TESSERA_SMTP_USER:-} TESSERA_SMTP_PASSWORD: ${TESSERA_SMTP_PASSWORD:-} TESSERA_SMTP_FROM: ${TESSERA_SMTP_FROM:-Tessera } + # Fallback mailbox for the in-app bug report button. Empty = only the + # per-tenant setting in Administrator -> SMTP ("Fehlermeldungen an") applies. + TESSERA_BUGREPORT_TO: ${TESSERA_BUGREPORT_TO:-} TESSERA_APP_URL: ${APP_URL:-http://localhost:3000} # Unset used to resolve to an empty value and only fail later, inside the # API, with a stack trace. Fail at compose level with a usable message diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 2e5cb8b..5590b9c 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -173,6 +173,7 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | calendar | 0 | 12 | 0 | **war 12/0** — Aufgabe 2 (260911-cwh) hat `calendar.service.ts` vollständig auf `forTenant()` umgestellt: `getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rückschreibungen von `fetchAndCacheEvents` — je Methode ein Klient. Anders als bei den sieben Bereichen davor bleibt KEIN ungebundener Rest übrig: `CalendarSource` trägt eine Pflicht-Mandantenkennung, und kein Pfad dieses Bereichs liest über Mandanten hinweg | | tenant | 8 | 3 | 0 | **war 8/0** — 260911-e2s (Aufgabe 3) hat drei gebundene Benutzerzähler in `tenant.controller.ts` eingeführt (Fan-out je Mandant nach dem Muster von `UserService.findAllForPlatformAdmin`, ersetzt die drei vorherigen Relationszähler); die acht `tenant`-Zugriffe selbst BLEIBEN ungebunden — `Tenant` trägt keine Regel in irgendeiner ausgelieferten Migration (260911-e2s Aufgabe 1, Prüfung 1/2), hier ist Ungebundenheit richtig, nicht geduldet | | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | +| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | settings | 0 | 3 | 0 | **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | **Summe** | **61** | **179** | **5** | **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | @@ -660,6 +661,7 @@ werden. |---|---|---|---|---| | apps/api/src/auth/auth.service.ts | passwordResetToken | muss-mandantengebunden | gebunden | Kein eigenes `tenantId`, RLS ueber Join auf `User` (Migration 20260618112133). `requestPasswordReset`/`resetPassword` laufen vollstaendig ueber `forTenant()` (Etappe 1, WINDOWS #20, Aufgabe 1) — von der alten, nur `this.prisma.*` erkennenden Suche nie erfasst, weil bereits gebunden; die erweiterte Erkennung aus Aufgabe 2 (260909-ipc) macht diese Fundstelle erstmals sichtbar. | | apps/api/src/auth/auth.service.ts | user | muss-mandantengebunden | gebunden | Klassenkorrektur (260911-fh9, Aufgabe 2/3): wechselt von `gemischt` auf `gebunden` — `getMe`, `changePassword`, `adminResetPassword` binden seit Aufgabe 2 je über GENAU EINEN Klienten `tenantPrisma` an den Mandanten aus dem Sitzungsnachweis (`@CurrentUser().tenantId`); für die oberste Rolle (SUPER_ADMIN) löst der Controller den Mandanten des ZIELS über den gebundenen Fan-out `UserService.findByIdForPlatformAdmin` auf. `adminResetPassword` verweigert zusätzlich einem Nicht-SUPER_ADMIN das Kennwort eines SUPER_ADMIN (T-FH9-04). Die drei Anmeldesuchen (`validateUser`, `requestPasswordReset`, `resetPassword`) laufen weiterhin über die drei SECURITY-DEFINER-Funktionen (`$queryRaw`, keine Modellzugriffe — `$` liegt nicht in `[a-zA-Z]`) und bleiben unverändert auf dem ungebundenen Klienten. Etappe-3-Vorbehalt: die Bindung hängt am Claim `tenantId` und an `User.id` (plattformweite UUID), nicht an `username`/`email` — der Anmeldeweg-Umbau für je Mandant eindeutige Anmeldenamen betrifft diese Bindung nicht, siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt "## Bereich auth", (h4)(a). | +| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Widget-Anordnung eines Nutzers, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt auf der plattformweit eindeutigen `userId`, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. | | apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |