feat(bug-reports): Herkunft der Fehlermeldung im Betreff-Kuerzel und als Zeile Herkunft ausweisen
WebView2 (Windows) sieht im User-Agent aus wie Edge, WebKitGTK (Linux) wie Safari — im Postfach war eine Client-Meldung von einer Browser-Meldung nicht zu unterscheiden. Neuer reiner Helfer origin.ts leitet aus vier optionalen DTO-Feldern (Desktop-App) bzw. dem User-Agent (Browser) ein Betreff-Kuerzel und eine Zeile "Herkunft: ..." ab; rein informativ, laengenbegrenzt, nichts wird gespeichert (T-GZA-01). Browser-Pfad ist damit Ende-zu-Ende fertig. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g2npLxzH5gZpg8s2S6vKh
This commit is contained in:
@@ -8,7 +8,7 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
/**
|
||||
* BugReportsController.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Drei Tests an der Grenze Browser -> API:
|
||||
* Vier 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
|
||||
@@ -16,6 +16,8 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
|
||||
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
|
||||
* `@Roles`-Metadatum, Pfad `bug-reports`.
|
||||
* 4. (quick-260918-gza) die vier neuen Client-Felder bleiben optional
|
||||
* und laengenbegrenzt erhalten.
|
||||
*/
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const meta = { type: 'body' as const, metatype: BugReportDto };
|
||||
@@ -63,4 +65,29 @@ describe('BugReportsController (quick-260914-m97)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
|
||||
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
|
||||
});
|
||||
|
||||
it('Test 4 (quick-260918-gza): die vier Client-Felder bleiben optional und laengenbegrenzt erhalten', async () => {
|
||||
const withDesktop = (await pipe.transform(
|
||||
{ ...baseBody, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' },
|
||||
meta,
|
||||
)) as any;
|
||||
expect(withDesktop.clientKind).toBe('desktop');
|
||||
expect(withDesktop.clientOs).toBe('windows');
|
||||
expect(withDesktop.clientVersion).toBe('1.2.0');
|
||||
expect(withDesktop.clientCommit).toBe('');
|
||||
|
||||
const withoutClientFields = (await pipe.transform({ ...baseBody }, meta)) as any;
|
||||
expect(withoutClientFields.clientKind).toBeUndefined();
|
||||
|
||||
await expect(pipe.transform({ ...baseBody, clientKind: 'tablet' }, meta)).rejects.toThrow(BadRequestException);
|
||||
await expect(pipe.transform({ ...baseBody, clientOs: 'x'.repeat(21) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(pipe.transform({ ...baseBody, clientVersion: 'x'.repeat(41) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(pipe.transform({ ...baseBody, clientCommit: 'x'.repeat(41) }, meta)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BugReportsService } from './bug-reports.service';
|
||||
/**
|
||||
* BugReportsService.spec — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Acht Tests, darunter die vier Falsifizierungen des Plans:
|
||||
* Zehn Tests, darunter die vier Falsifizierungen des Plans:
|
||||
* (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem
|
||||
* Fenster (Fake-Timer) wieder durch;
|
||||
* (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet;
|
||||
@@ -20,6 +20,8 @@ import { BugReportsService } from './bug-reports.service';
|
||||
* (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der
|
||||
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
|
||||
* ausschliesslich mit der Kennung aus dem Sitzungsnachweis.
|
||||
* Test 9 und 10 (quick-260918-gza): Betreff-Kuerzel und Zeile `Herkunft:`
|
||||
* fuer den Desktop- und den Browser-Pfad, Protokollzeile mit Kuerzel.
|
||||
*
|
||||
* `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen
|
||||
* Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert;
|
||||
@@ -135,7 +137,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
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');
|
||||
expect(report.subject).toBe('[Tessera Fehlermeldung] [Browser] v1.2.3 beta - /admin/users?tab=x');
|
||||
for (const needle of [
|
||||
'Knopf tut nichts',
|
||||
'/admin/users?tab=x',
|
||||
@@ -145,6 +147,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
't1',
|
||||
'v1.2.3 (beta) abc1234',
|
||||
'Tessera API v9.9.9 (live)',
|
||||
'Herkunft: Browser — unbekannt auf unbekannt',
|
||||
'UA',
|
||||
'1920x1080',
|
||||
'[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}',
|
||||
@@ -291,4 +294,53 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
||||
expect(report.text).not.toContain('Eindringling');
|
||||
expect(report.text).not.toContain('fremd@x.invalid');
|
||||
});
|
||||
|
||||
it('Test 9 (quick-260918-gza): Desktop/Windows — Betreff-Kuerzel, Zeile Herkunft VOR Browser:, Protokollzeile mit Kuerzel', async () => {
|
||||
const { service, mailService } = makeService({});
|
||||
const edgeUa =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
|
||||
|
||||
await service.submit(
|
||||
sessionUser,
|
||||
{
|
||||
...baseDto,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
userAgent: edgeUa,
|
||||
} as any,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
|
||||
expect(report.subject.startsWith('[Tessera Fehlermeldung] [Desktop/Windows] v1.2.3 beta - ')).toBe(true);
|
||||
expect(report.text).toContain('Herkunft: Desktop-App (Windows), Tessera-App 1.2.0 · Stand a6d1a64');
|
||||
expect(report.text).toContain('Browser: Mozilla/5.0 (Windows NT 10.0');
|
||||
expect(report.text).toContain('Fenster: 1920x1080');
|
||||
expect(report.text.indexOf('Herkunft:')).toBeLessThan(report.text.indexOf('Browser:'));
|
||||
|
||||
const { service: freshService, mailService: freshMail } = makeService({});
|
||||
freshMail.sendBugReport.mockClear();
|
||||
(freshService as any).logger.log.mockClear();
|
||||
await freshService.submit(
|
||||
sessionUser,
|
||||
{ ...baseDto, clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: 'a6d1a64', userAgent: edgeUa } as any,
|
||||
undefined,
|
||||
);
|
||||
expect((freshService as any).logger.log).toHaveBeenCalledTimes(1);
|
||||
expect((freshService as any).logger.log.mock.calls[0][0]).toContain('[Desktop/Windows]');
|
||||
});
|
||||
|
||||
it('Test 10 (quick-260918-gza): Browser mit echtem User-Agent — Betreff und Zeile Herkunft nennen Browser und Version', async () => {
|
||||
const { service, mailService } = makeService({});
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
||||
|
||||
await service.submit(sessionUser, { ...baseDto, clientKind: 'browser', userAgent: chromeUa } as any, undefined);
|
||||
|
||||
const report = (mailService.sendBugReport.mock.calls[0] as any[])[2];
|
||||
expect(report.subject).toContain('[Browser]');
|
||||
expect(report.text).toContain('Herkunft: Browser — Chrome 129 auf Windows');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
import { describeOrigin } from './origin';
|
||||
|
||||
/**
|
||||
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
|
||||
@@ -43,6 +44,14 @@ import { BugReportDto } from './dto/bug-report.dto';
|
||||
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
|
||||
* einen gebundenen Klienten — Zeile in
|
||||
* docs/mandantentrennung-zugriffsklassifikation.md).
|
||||
*
|
||||
* Herkunft (quick-260918-gza): der Betreff traegt seit dieser Aenderung
|
||||
* ein Kuerzel (`[Browser]`, `[Desktop/Windows]`, `[Desktop/Linux]`, oder
|
||||
* `[Desktop]` bei einem alten Client ohne Details), damit sich das
|
||||
* Postfach danach sortieren laesst — WebView2 sieht sonst aus wie Edge,
|
||||
* WebKitGTK wie Safari. Der rohe User-Agent bleibt in der Zeile
|
||||
* `Browser:` unveraendert erhalten, die Ableitung selbst steht in
|
||||
* `origin.ts` (dort auch T-GZA-01: rein informativ, laengenbegrenzt).
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
@@ -119,9 +128,10 @@ export class BugReportsService {
|
||||
const email: string = row?.email ?? '-';
|
||||
const role: string = row?.role ?? user.role;
|
||||
|
||||
// (5) Betreff
|
||||
// (5) Betreff — Herkunfts-Kuerzel direkt nach dem festen Praefix (quick-260918-gza).
|
||||
const origin = describeOrigin(dto);
|
||||
const pageShort = dto.page.slice(0, 120);
|
||||
const subject = `[Tessera Fehlermeldung] ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
|
||||
const subject = `[Tessera Fehlermeldung] ${origin.tag} ${dto.webVersion} ${dto.webChannel} - ${pageShort}`;
|
||||
|
||||
// (6) Text
|
||||
const bytes = file ? file.buffer.length : 0;
|
||||
@@ -139,6 +149,7 @@ export class BugReportsService {
|
||||
`Mandant: ${user.tenantId}`,
|
||||
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
|
||||
`API: ${formatAppVersionLine()}`,
|
||||
`Herkunft: ${origin.line}`,
|
||||
`Browser: ${dto.userAgent}`,
|
||||
`Fenster: ${dto.viewport}`,
|
||||
'',
|
||||
@@ -164,8 +175,10 @@ export class BugReportsService {
|
||||
}
|
||||
|
||||
// (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
|
||||
// Das Kuerzel ist ein aufgezaehlter Wert aus origin.ts, nie ein roher
|
||||
// Client-String — deshalb protokollierbar (T-GZA-01).
|
||||
this.logger.log(
|
||||
`Bug report from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
|
||||
`Bug report ${origin.tag} from ${user.username} (tenant ${user.tenantId}) sent to ${to} — page ${pageShort}, screenshot ${bytes} bytes`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
@@ -21,6 +22,16 @@ import {
|
||||
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
|
||||
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
|
||||
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
|
||||
*
|
||||
* Vier weitere Felder (`clientKind`, `clientOs`, `clientVersion`,
|
||||
* `clientCommit`) kommen seit quick-260918-gza vom Web-Client dazu — im
|
||||
* Browser als `clientKind=browser` mit leeren uebrigen Feldern, in der
|
||||
* Desktop-App aus dem Cookie `tessera_desktop_client`. Sie sind optional,
|
||||
* damit aeltere Web-Baue weiterhin gueltig senden (der Dienst faellt ohne
|
||||
* sie auf `browser` zurueck); `whitelist: true` verlangt ihre Deklaration
|
||||
* hier, sonst wuerde die Pipe sie stillschweigend entfernen. Sie dienen
|
||||
* ausschliesslich der Anzeige in der Mail und sind laengenbegrenzt
|
||||
* (T-GZA-01) — siehe `origin.ts`.
|
||||
*/
|
||||
export class BugReportDto {
|
||||
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
|
||||
@@ -77,4 +88,27 @@ export class BugReportDto {
|
||||
@IsString({ each: true })
|
||||
@MaxLength(1000, { each: true })
|
||||
errors!: string[];
|
||||
|
||||
/** `desktop` oder `browser` — vom Web-Client gesetzt, siehe Kopfkommentar. */
|
||||
@IsOptional()
|
||||
@IsIn(['desktop', 'browser'])
|
||||
clientKind?: 'desktop' | 'browser';
|
||||
|
||||
/** Betriebssystem der Desktop-App (`windows`, `linux`, `macos`, …), unbeglaubigt. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
clientOs?: string;
|
||||
|
||||
/** Version der Desktop-App (`CARGO_PKG_VERSION`), unbeglaubigt. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
clientVersion?: string;
|
||||
|
||||
/** Commit-Stempel der Desktop-App (`APP_COMMIT`), darf leer sein. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
clientCommit?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeOrigin, parseUserAgent } from './origin';
|
||||
|
||||
/**
|
||||
* origin.spec.ts — NEU (quick-260918-gza, Herkunft einer Fehlermeldung).
|
||||
*
|
||||
* Zehn Tests fuer die beiden reinen Funktionen aus origin.ts:
|
||||
* 1-5 `parseUserAgent` fuer Desktop- und Mobil-Browser (Reihenfolge der
|
||||
* Muster: Edge/Opera vor Chrome, Safari nur ohne Chrome-Token,
|
||||
* Android/iOS vor Linux/macOS);
|
||||
* 6-8 `describeOrigin` im Desktop-Fall (mit/ohne Commit, ganz ohne
|
||||
* Details — alter Client);
|
||||
* 9 Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder
|
||||
* fehlen (alter Web-Bau);
|
||||
* 10 Bereinigung: kein Zeilenumbruch, kein Markup, hoechstens 40
|
||||
* Zeichen je Wert (T-GZA-01).
|
||||
*/
|
||||
|
||||
const EDGE_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.52';
|
||||
const CHROME_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36';
|
||||
const FIREFOX_LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0';
|
||||
const SAFARI_MACOS_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
|
||||
const ANDROID_CHROME_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
|
||||
const IPAD_SAFARI_UA =
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
||||
const OPERA_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 OPR/114.0.0.0';
|
||||
const WEBKITGTK_LINUX_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||
|
||||
describe('origin (quick-260918-gza)', () => {
|
||||
it('Test 1: Edge auf Windows — Edge gewinnt vor Chrome', () => {
|
||||
expect(parseUserAgent(EDGE_WINDOWS_UA)).toEqual({ browser: 'Edge 129', os: 'Windows' });
|
||||
expect(describeOrigin({ clientKind: 'browser', userAgent: EDGE_WINDOWS_UA })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — Edge 129 auf Windows',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 2: derselbe UA ohne Edg/ — Chrome auf Windows', () => {
|
||||
expect(parseUserAgent(CHROME_WINDOWS_UA)).toEqual({ browser: 'Chrome 129', os: 'Windows' });
|
||||
});
|
||||
|
||||
it('Test 3: Firefox auf Linux', () => {
|
||||
expect(parseUserAgent(FIREFOX_LINUX_UA)).toEqual({ browser: 'Firefox 130', os: 'Linux' });
|
||||
expect(describeOrigin({ userAgent: FIREFOX_LINUX_UA }).line).toBe('Browser — Firefox 130 auf Linux');
|
||||
});
|
||||
|
||||
it('Test 4: Safari auf macOS — Version aus Version/, nur ohne Chrome-Token', () => {
|
||||
expect(parseUserAgent(SAFARI_MACOS_UA)).toEqual({ browser: 'Safari 17', os: 'macOS' });
|
||||
});
|
||||
|
||||
it('Test 5: Mobil — Android statt Linux, iOS statt macOS, Opera erkannt', () => {
|
||||
expect(parseUserAgent(ANDROID_CHROME_UA).os).toBe('Android');
|
||||
const ipad = parseUserAgent(IPAD_SAFARI_UA);
|
||||
expect(ipad.os).toBe('iOS');
|
||||
expect(ipad.browser).toBe('Safari 17');
|
||||
expect(parseUserAgent(OPERA_UA).browser).toBe('Opera 114');
|
||||
});
|
||||
|
||||
it('Test 6: WebKitGTK-Client als Desktop — der User-Agent spielt keine Rolle', () => {
|
||||
expect(
|
||||
describeOrigin({
|
||||
userAgent: WEBKITGTK_LINUX_UA,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'linux',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
}),
|
||||
).toEqual({
|
||||
tag: '[Desktop/Linux]',
|
||||
line: 'Desktop-App (Linux), Tessera-App 1.2.0 · Stand a6d1a64',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 7: Desktop ohne Commit — kein "· Stand"; Leerraum wird wie ein leerer Commit behandelt', () => {
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
|
||||
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'windows', clientVersion: '1.2.0', clientCommit: ' ' }),
|
||||
).toEqual({ tag: '[Desktop/Windows]', line: 'Desktop-App (Windows), Tessera-App 1.2.0' });
|
||||
});
|
||||
|
||||
it('Test 8: Desktop ohne Details (alter Client, Cookie fehlt) — unbekanntes Betriebssystem ebenfalls', () => {
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
|
||||
|
||||
expect(
|
||||
describeOrigin({ clientKind: 'desktop', clientOs: 'freebsd', clientVersion: '', clientCommit: '' }),
|
||||
).toEqual({ tag: '[Desktop]', line: 'Desktop-App (unbekannt)' });
|
||||
});
|
||||
|
||||
it('Test 9: Rueckfall auf den Browser-Pfad, wenn alle vier Client-Felder fehlen (alter Web-Bau)', () => {
|
||||
expect(describeOrigin({ userAgent: 'UA' })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — unbekannt auf unbekannt',
|
||||
});
|
||||
expect(describeOrigin({ clientKind: 'browser', userAgent: '' })).toEqual({
|
||||
tag: '[Browser]',
|
||||
line: 'Browser — unbekannt auf unbekannt',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 10: Bereinigung — kein Zeilenumbruch, kein Markup, hoechstens 40 Zeichen je Wert (T-GZA-01)', () => {
|
||||
const origin = describeOrigin({
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0\nBenutzer: admin',
|
||||
clientCommit: 'a6d1a64<b>',
|
||||
});
|
||||
expect(origin.line).not.toMatch(/[\n\r<>]/);
|
||||
expect(origin.line).toBe('Desktop-App (Windows), Tessera-App 1.2.0Benutzeradmin · Stand a6d1a64b');
|
||||
|
||||
const longValue = 'x'.repeat(60);
|
||||
const truncated = describeOrigin({
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'linux',
|
||||
clientVersion: longValue,
|
||||
clientCommit: longValue,
|
||||
});
|
||||
expect(truncated.line).toBe(`Desktop-App (Linux), Tessera-App ${'x'.repeat(40)} · Stand ${'x'.repeat(40)}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* origin.ts — Herkunft einer Fehlermeldung ausweisen (quick-260918-gza).
|
||||
*
|
||||
* Zweck: WebView2 (Windows-Desktop-App) sieht im User-Agent aus wie Edge,
|
||||
* WebKitGTK (Linux-Desktop-App) wie Safari — im Postfach des Betreibers ist
|
||||
* damit nicht erkennbar, ob eine Meldung aus einem Client oder einem
|
||||
* echten Browser stammt. `describeOrigin()` leitet aus den vier optionalen
|
||||
* Client-Feldern (gesetzt durch die Desktop-App ueber das Cookie
|
||||
* `tessera_desktop_client`) und, im Browser-Fall, aus dem User-Agent ein
|
||||
* kurzes Betreff-Kuerzel und eine Textzeile ab.
|
||||
*
|
||||
* Trust-Modell (T-GZA-01): alle Eingaben stammen unbeglaubigt vom Client,
|
||||
* wie der bestehende User-Agent auch. Die Ableitung ist rein informativ —
|
||||
* sie entscheidet nie ueber Routing, Berechtigung oder Speicherung, und
|
||||
* nichts davon wird in der Datenbank abgelegt. `clean()` laesst nur ein
|
||||
* begrenztes Alphabet und hoechstens 40 Zeichen zu, damit weder ein
|
||||
* Zeilenumbruch noch Markup in die E-Mail gelangt; das Betriebssystem wird
|
||||
* ausserdem auf eine feste Menge von Labels abgebildet.
|
||||
*
|
||||
* Warum Regex statt Bibliothek: fuenf Browser und fuenf Betriebssysteme
|
||||
* reichen aus, um ein Postfach sinnvoll zu sortieren — ein neues Paket
|
||||
* (etwa ua-parser-js) waere fuer diesen Umfang unverhaeltnismaessig.
|
||||
*/
|
||||
|
||||
export type ClientKind = 'desktop' | 'browser';
|
||||
|
||||
export interface OriginInput {
|
||||
clientKind?: string;
|
||||
clientOs?: string;
|
||||
clientVersion?: string;
|
||||
clientCommit?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface Origin {
|
||||
/** Betreff-Kuerzel in eckigen Klammern, z. B. `[Browser]`, `[Desktop/Windows]`. */
|
||||
tag: string;
|
||||
/** Text NACH dem Label `Herkunft: ` — der Dienst setzt das Label davor. */
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface ParsedUserAgent {
|
||||
browser: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
const UNKNOWN = 'unbekannt';
|
||||
|
||||
/**
|
||||
* Entfernt alles ausser `[A-Za-z0-9.+_-]` und kuerzt auf `max` Zeichen
|
||||
* (Vorgabe 40) — kein Zeilenumbruch, kein Markup in Betreff oder Text
|
||||
* (T-GZA-01).
|
||||
*/
|
||||
function clean(value: unknown, max = 40): string {
|
||||
return String(value ?? '')
|
||||
.replace(/[^A-Za-z0-9.+_-]/g, '')
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet Browsername samt Hauptversion und Betriebssystem aus einem
|
||||
* User-Agent ab. Reihenfolge ist entscheidend: Android-UAs enthalten
|
||||
* `Linux`, iPad-UAs enthalten `like Mac OS X`, Edge- und Opera-UAs
|
||||
* enthalten sowohl `Chrome/` als auch `Safari/` — die spezifischeren
|
||||
* Muster muessen deshalb zuerst gewinnen.
|
||||
*/
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
const value = ua ?? '';
|
||||
|
||||
let browser = UNKNOWN;
|
||||
const edg = value.match(/Edg\/(\d+)/);
|
||||
const opr = value.match(/OPR\/(\d+)/);
|
||||
const firefox = value.match(/Firefox\/(\d+)/);
|
||||
const chrome = value.match(/(?:Chrome|CriOS)\/(\d+)/);
|
||||
const hasSafariToken = /Safari\//.test(value);
|
||||
const hasChromeToken = /Chrome\//.test(value);
|
||||
|
||||
if (edg) {
|
||||
browser = `Edge ${edg[1]}`;
|
||||
} else if (opr) {
|
||||
browser = `Opera ${opr[1]}`;
|
||||
} else if (firefox) {
|
||||
browser = `Firefox ${firefox[1]}`;
|
||||
} else if (chrome) {
|
||||
browser = `Chrome ${chrome[1]}`;
|
||||
} else if (hasSafariToken && !hasChromeToken) {
|
||||
const version = value.match(/Version\/(\d+)/);
|
||||
browser = version ? `Safari ${version[1]}` : 'Safari';
|
||||
}
|
||||
|
||||
let os = UNKNOWN;
|
||||
if (/Windows NT/.test(value)) {
|
||||
os = 'Windows';
|
||||
} else if (/Android/.test(value)) {
|
||||
os = 'Android';
|
||||
} else if (/iPhone|iPad|iPod/.test(value)) {
|
||||
os = 'iOS';
|
||||
} else if (/Mac OS X|Macintosh/.test(value)) {
|
||||
os = 'macOS';
|
||||
} else if (/Linux|X11/.test(value)) {
|
||||
os = 'Linux';
|
||||
}
|
||||
|
||||
return { browser, os };
|
||||
}
|
||||
|
||||
/** Bildet `clean(clientOs)` auf eines von drei festen Labels ab. */
|
||||
function desktopOsLabel(clientOs: string | undefined): string {
|
||||
switch (clean(clientOs).toLowerCase()) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
default:
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet Betreff-Kuerzel und Herkunftszeile ab. Desktop-Fall: der
|
||||
* User-Agent spielt keine Rolle, nur die vier Client-Felder zaehlen
|
||||
* (dieselbe Regel wie `client_info_label` im Rust-Client — Version und
|
||||
* Commit werden nur zusammen mit " · Stand " verbunden, ein leerer
|
||||
* Commit faellt weg). Alles andere (auch fehlende Felder, alter
|
||||
* Web-Bau) faellt auf den Browser-Pfad mit `parseUserAgent` zurueck.
|
||||
*/
|
||||
export function describeOrigin(input: OriginInput): Origin {
|
||||
if (input.clientKind === 'desktop') {
|
||||
const osLabel = desktopOsLabel(input.clientOs);
|
||||
const version = clean(input.clientVersion);
|
||||
const commit = clean(input.clientCommit);
|
||||
|
||||
let appLabel = '';
|
||||
if (version && commit) {
|
||||
appLabel = `Tessera-App ${version} · Stand ${commit}`;
|
||||
} else if (version) {
|
||||
appLabel = `Tessera-App ${version}`;
|
||||
}
|
||||
|
||||
const line = appLabel ? `Desktop-App (${osLabel}), ${appLabel}` : `Desktop-App (${osLabel})`;
|
||||
const tag = osLabel === UNKNOWN ? '[Desktop]' : `[Desktop/${osLabel}]`;
|
||||
return { tag, line };
|
||||
}
|
||||
|
||||
const { browser, os } = parseUserAgent(input.userAgent ?? '');
|
||||
return { tag: '[Browser]', line: `Browser — ${browser} auf ${os}` };
|
||||
}
|
||||
Reference in New Issue
Block a user