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).
|
* 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
|
* 1. die globale Pipe (`whitelist: true, transform: true`, wie in
|
||||||
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
|
* `main.ts`) entfernt Fremdfelder wie `tenantId` (T-M97-06) und
|
||||||
* normalisiert `errors` (multer/append-field liefert EIN Feld als
|
* 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);
|
* 2. die DTO-Grenzen greifen (31 Eintraege, 4001 Zeichen -> 400);
|
||||||
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
|
* 3. die Route steht JEDEM angemeldeten Benutzer offen — kein
|
||||||
* `@Roles`-Metadatum, Pfad `bug-reports`.
|
* `@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 pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
const meta = { type: 'body' as const, metatype: BugReportDto };
|
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(ROLES_KEY, BugReportsController.prototype.submit)).toBeUndefined();
|
||||||
expect(Reflect.getMetadata('path', BugReportsController)).toBe('bug-reports');
|
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).
|
* 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
|
* (a) Drossel: der sechste Bericht in zehn Minuten -> 429, nach dem
|
||||||
* Fenster (Fake-Timer) wieder durch;
|
* Fenster (Fake-Timer) wieder durch;
|
||||||
* (b) manipulierte Bilddatei ohne PNG-Kopf -> 400, nie versendet;
|
* (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
|
* (d) Fremdfelder im Rumpf (tenantId/userId) aendern NICHTS an der
|
||||||
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
|
* Mandantenkennung — Empfaenger, Benutzerzeile und Versand laufen
|
||||||
* ausschliesslich mit der Kennung aus dem Sitzungsnachweis.
|
* 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
|
* `forTenant` wird wie in `user.controller.spec.ts` durch einen gebundenen
|
||||||
* Fake-Klienten ersetzt, der nur Zeilen des eigenen Mandanten liefert;
|
* 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[];
|
const [tenantId, to, report] = mailService.sendBugReport.mock.calls[0] as any[];
|
||||||
expect(tenantId).toBe('t1');
|
expect(tenantId).toBe('t1');
|
||||||
expect(to).toBe('fehler@a.example.invalid');
|
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 [
|
for (const needle of [
|
||||||
'Knopf tut nichts',
|
'Knopf tut nichts',
|
||||||
'/admin/users?tab=x',
|
'/admin/users?tab=x',
|
||||||
@@ -145,6 +147,7 @@ describe('BugReportsService (quick-260914-m97)', () => {
|
|||||||
't1',
|
't1',
|
||||||
'v1.2.3 (beta) abc1234',
|
'v1.2.3 (beta) abc1234',
|
||||||
'Tessera API v9.9.9 (live)',
|
'Tessera API v9.9.9 (live)',
|
||||||
|
'Herkunft: Browser — unbekannt auf unbekannt',
|
||||||
'UA',
|
'UA',
|
||||||
'1920x1080',
|
'1920x1080',
|
||||||
'[2026-09-14T09:59:00.000Z] fetch: GET /modules -> 500 {"statusCode":500}',
|
'[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('Eindringling');
|
||||||
expect(report.text).not.toContain('fremd@x.invalid');
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
import { SettingsService } from '../settings/settings.service';
|
||||||
import { BugReportDto } from './dto/bug-report.dto';
|
import { BugReportDto } from './dto/bug-report.dto';
|
||||||
|
import { describeOrigin } from './origin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BugReportsService — Fehler-melden-Knopf (quick-260914-m97).
|
* 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
|
* Benutzer ausschliesslich aus dem Sitzungsnachweis, Benutzerzeile ueber
|
||||||
* einen gebundenen Klienten — Zeile in
|
* einen gebundenen Klienten — Zeile in
|
||||||
* docs/mandantentrennung-zugriffsklassifikation.md).
|
* 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;
|
const WINDOW_MS = 10 * 60 * 1000;
|
||||||
@@ -119,9 +128,10 @@ export class BugReportsService {
|
|||||||
const email: string = row?.email ?? '-';
|
const email: string = row?.email ?? '-';
|
||||||
const role: string = row?.role ?? user.role;
|
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 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
|
// (6) Text
|
||||||
const bytes = file ? file.buffer.length : 0;
|
const bytes = file ? file.buffer.length : 0;
|
||||||
@@ -139,6 +149,7 @@ export class BugReportsService {
|
|||||||
`Mandant: ${user.tenantId}`,
|
`Mandant: ${user.tenantId}`,
|
||||||
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
|
`Web: ${dto.webVersion} (${dto.webChannel}) ${dto.webCommit}`.trimEnd(),
|
||||||
`API: ${formatAppVersionLine()}`,
|
`API: ${formatAppVersionLine()}`,
|
||||||
|
`Herkunft: ${origin.line}`,
|
||||||
`Browser: ${dto.userAgent}`,
|
`Browser: ${dto.userAgent}`,
|
||||||
`Fenster: ${dto.viewport}`,
|
`Fenster: ${dto.viewport}`,
|
||||||
'',
|
'',
|
||||||
@@ -164,8 +175,10 @@ export class BugReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// (9) Genau eine Protokollzeile — nie Beschreibung, nie Bild (T-M97-07).
|
// (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(
|
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 };
|
return { sent: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Expose, Transform } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
@@ -21,6 +22,16 @@ import {
|
|||||||
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
|
* Dienst nimmt beides ausschliesslich aus dem Sitzungsnachweis
|
||||||
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
|
* (`@CurrentUser()`), und `whitelist: true` der globalen ValidationPipe
|
||||||
* entfernt jedes Fremdfeld, das ein Client hier trotzdem mitschickt.
|
* 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 {
|
export class BugReportDto {
|
||||||
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
|
/** Freitext „Was ist passiert?“ — optional, hoechstens 4000 Zeichen. */
|
||||||
@@ -77,4 +88,27 @@ export class BugReportDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@MaxLength(1000, { each: true })
|
@MaxLength(1000, { each: true })
|
||||||
errors!: string[];
|
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