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:
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user