feat(web): Herkunft der Fehlermeldung — Cookie tessera_desktop_client und Client-Felder in der Nutzlast
Die Middleware liest jetzt zusaetzlich dv/dc/dos aus der Anfrage und legt daraus das Cookie tessera_desktop_client an (bereinigt per Muster, nur wenn alle drei Werte gueltig sind); desktop-client.ts liest es zurueck. Der Fehler-melden-Dialog fuellt daraus vier neue Nutzlastfelder (clientKind/clientOs/clientVersion/clientCommit), damit die API die Herkunft der Meldung ausweisen kann. Ohne das zweite Cookie (alter Client) bleibt es bei "Desktop-App (unbekannt)". 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,14 +8,19 @@ import { computeCaptureSize } from '@/lib/bug-report-api';
|
||||
/**
|
||||
* bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Elf Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem Dialog
|
||||
* aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs, dass noch
|
||||
* KEIN `role="dialog"` im DOM steht. `html-to-image` selbst laeuft in jsdom
|
||||
* nicht (gemessen zur Planungszeit: `HTMLVideoElement is not defined`, kein
|
||||
* Canvas-Backend) — deshalb der Mock; der Bildbeweis kommt aus dem Browser.
|
||||
* Dreizehn Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem
|
||||
* Dialog aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs,
|
||||
* dass noch KEIN `role="dialog"` im DOM steht. `html-to-image` selbst
|
||||
* laeuft in jsdom nicht (gemessen zur Planungszeit:
|
||||
* `HTMLVideoElement is not defined`, kein Canvas-Backend) — deshalb der
|
||||
* Mock; der Bildbeweis kommt aus dem Browser.
|
||||
*
|
||||
* Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster
|
||||
* `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
|
||||
*
|
||||
* Test 12/13 (quick-260918-gza): die vier Herkunftsfelder in der Nutzlast,
|
||||
* abhaengig vom Cookie `tessera_desktop_client` — jsdom hat ohne gesetztes
|
||||
* Cookie keinen Desktop-Client, darum bleibt Test 2 beim Browser-Fall.
|
||||
*/
|
||||
const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({
|
||||
mockToPng: vi.fn(),
|
||||
@@ -86,9 +91,15 @@ beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
function clearDesktopCookies() {
|
||||
document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
document.cookie = 'tessera_desktop_client=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearErrorBuffer();
|
||||
clearDesktopCookies();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -142,6 +153,10 @@ describe('BugReportButton (quick-260914-m97)', () => {
|
||||
expect(body.get('userAgent')).toBeTruthy();
|
||||
expect(body.get('viewport')).toMatch(/^\d+x\d+$/);
|
||||
expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(body.get('clientKind')).toBe('browser');
|
||||
expect(body.get('clientOs')).toBe('');
|
||||
expect(body.get('clientVersion')).toBe('');
|
||||
expect(body.get('clientCommit')).toBe('');
|
||||
expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true);
|
||||
const shot = body.get('screenshot');
|
||||
expect(shot).toBeInstanceOf(Blob);
|
||||
@@ -271,6 +286,41 @@ describe('BugReportButton (quick-260914-m97)', () => {
|
||||
expect(screen.queryByText(specific)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 12 (quick-260918-gza): Desktop-Cookies gesetzt -> clientKind desktop mit Os/Version/Commit aus dem Cookie', async () => {
|
||||
document.cookie = 'tessera_desktop=1; path=/';
|
||||
document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/';
|
||||
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await screen.findByText(T.sent);
|
||||
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.get('clientKind')).toBe('desktop');
|
||||
expect(body.get('clientOs')).toBe('windows');
|
||||
expect(body.get('clientVersion')).toBe('1.2.0');
|
||||
expect(body.get('clientCommit')).toBe('a6d1a64');
|
||||
});
|
||||
|
||||
it('Test 13 (quick-260918-gza, alter Client): nur tessera_desktop ohne tessera_desktop_client -> desktop ohne Details', async () => {
|
||||
document.cookie = 'tessera_desktop=1; path=/';
|
||||
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await screen.findByText(T.sent);
|
||||
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.get('clientKind')).toBe('desktop');
|
||||
expect(body.get('clientOs')).toBe('');
|
||||
expect(body.get('clientVersion')).toBe('');
|
||||
expect(body.get('clientCommit')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { appVersion } from '@/lib/app-version';
|
||||
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
|
||||
import { getDesktopClientInfo, isDesktopClient } from '@/lib/desktop-client';
|
||||
import { formatErrorsForReport } from '@/lib/error-buffer';
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,11 @@ export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugRepor
|
||||
|
||||
const handleSend = async () => {
|
||||
setStatus('sending');
|
||||
// Herkunft (quick-260918-gza): tessera_desktop entscheidet Desktop/
|
||||
// Browser, tessera_desktop_client liefert die Details dazu; fehlt es
|
||||
// (alter Client), bleibt es bei Desktop ohne Details.
|
||||
const desktop = isDesktopClient();
|
||||
const info = desktop ? getDesktopClientInfo() : null;
|
||||
const result = await sendBugReport({
|
||||
description: description.trim(),
|
||||
page: window.location.pathname + window.location.search,
|
||||
@@ -65,6 +71,10 @@ export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugRepor
|
||||
clientTime: new Date().toISOString(),
|
||||
errors: formatErrorsForReport(),
|
||||
screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null,
|
||||
clientKind: desktop ? 'desktop' : 'browser',
|
||||
clientOs: info?.os ?? '',
|
||||
clientVersion: info?.version ?? '',
|
||||
clientCommit: info?.commit ?? '',
|
||||
});
|
||||
if (result.ok) {
|
||||
setStatus('sent');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendBugReport } from './bug-report-api';
|
||||
|
||||
/**
|
||||
* bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der
|
||||
* Komponententest (`bug-report-button.test.tsx`) die FormData von
|
||||
* `sendBugReport` — die reinen Nutzlastfelder (insbesondere die vier neuen
|
||||
* Herkunftsfelder) gehoeren aber an die Funktion selbst, unabhaengig vom
|
||||
* Dialog. Drei Tests: Desktop-Nutzlast, Browser-Nutzlast, Netzwerk-/
|
||||
* HTTP-Fehler.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
const basePayload = {
|
||||
description: 'x',
|
||||
page: '/a',
|
||||
webVersion: 'v1',
|
||||
webChannel: 'beta',
|
||||
webCommit: 'c',
|
||||
userAgent: 'UA',
|
||||
viewport: '1x1',
|
||||
clientTime: 't',
|
||||
errors: ['e1', 'e2'],
|
||||
screenshot: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('sendBugReport (quick-260918-gza)', () => {
|
||||
it('Test 1: Desktop-Nutzlast — clientKind/clientOs/clientVersion/clientCommit im FormData, errors mehrfach, kein screenshot-Feld', async () => {
|
||||
const result = await sendBugReport({
|
||||
...basePayload,
|
||||
clientKind: 'desktop',
|
||||
clientOs: 'windows',
|
||||
clientVersion: '1.2.0',
|
||||
clientCommit: 'a6d1a64',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/bug-reports')).toBe(true);
|
||||
expect(init.credentials).toBe('include');
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('clientKind')).toBe('desktop');
|
||||
expect(body.get('clientOs')).toBe('windows');
|
||||
expect(body.get('clientVersion')).toBe('1.2.0');
|
||||
expect(body.get('clientCommit')).toBe('a6d1a64');
|
||||
expect(body.getAll('errors')).toEqual(['e1', 'e2']);
|
||||
expect(body.has('screenshot')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 2: Browser-Nutzlast — die drei uebrigen Client-Felder sind vorhanden, aber leer (nicht weggelassen)', async () => {
|
||||
await sendBugReport({
|
||||
...basePayload,
|
||||
clientKind: 'browser',
|
||||
clientOs: '',
|
||||
clientVersion: '',
|
||||
clientCommit: '',
|
||||
});
|
||||
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.get('clientKind')).toBe('browser');
|
||||
expect(body.get('clientOs')).toBe('');
|
||||
expect(body.get('clientVersion')).toBe('');
|
||||
expect(body.get('clientCommit')).toBe('');
|
||||
});
|
||||
|
||||
it('Test 3: Netzwerkfehler -> { ok: false, status: 0 }; HTTP-Fehler -> { ok: false, status }', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(
|
||||
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).resolves.toEqual({ ok: false, status: 0 });
|
||||
|
||||
mockFetch.mockResolvedValueOnce(new Response('', { status: 429 }));
|
||||
await expect(
|
||||
sendBugReport({ ...basePayload, clientKind: 'browser', clientOs: '', clientVersion: '', clientCommit: '' }),
|
||||
).resolves.toEqual({ ok: false, status: 429 });
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@
|
||||
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
|
||||
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
|
||||
* Multipart-Grenze setzt der Browser selbst.
|
||||
*
|
||||
* Seit quick-260918-gza traegt die Nutzlast zusaetzlich vier Herkunftsfelder
|
||||
* (`clientKind`, `clientOs`, `clientVersion`, `clientCommit`) — im Browser
|
||||
* `clientKind=browser` mit leeren uebrigen Feldern, in der Desktop-App aus
|
||||
* `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts). Die API
|
||||
* leitet daraus das Betreff-Kuerzel und die Zeile `Herkunft:` ab.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -79,6 +85,10 @@ export interface BugReportPayload {
|
||||
clientTime: string;
|
||||
errors: string[];
|
||||
screenshot: Blob | null;
|
||||
clientKind: 'desktop' | 'browser';
|
||||
clientOs: string;
|
||||
clientVersion: string;
|
||||
clientCommit: string;
|
||||
}
|
||||
|
||||
export type BugReportResult = { ok: true } | { ok: false; status: number };
|
||||
@@ -93,6 +103,10 @@ export async function sendBugReport(p: BugReportPayload): Promise<BugReportResul
|
||||
body.append('userAgent', p.userAgent);
|
||||
body.append('viewport', p.viewport);
|
||||
body.append('clientTime', p.clientTime);
|
||||
body.append('clientKind', p.clientKind);
|
||||
body.append('clientOs', p.clientOs);
|
||||
body.append('clientVersion', p.clientVersion);
|
||||
body.append('clientCommit', p.clientCommit);
|
||||
for (const line of p.errors) body.append('errors', line);
|
||||
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DESKTOP_COOKIE_NAME, isDesktopClient, useIsDesktopClient } from './desktop-client';
|
||||
import {
|
||||
DESKTOP_CLIENT_COOKIE_NAME,
|
||||
DESKTOP_COOKIE_NAME,
|
||||
getDesktopClientInfo,
|
||||
isDesktopClient,
|
||||
parseDesktopClientCookie,
|
||||
useIsDesktopClient,
|
||||
} from './desktop-client';
|
||||
|
||||
/**
|
||||
* desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das
|
||||
* Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn
|
||||
* der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt.
|
||||
* Seit quick-260918-gza zusaetzlich `parseDesktopClientCookie()` /
|
||||
* `getDesktopClientInfo()` fuer das zweite Cookie `tessera_desktop_client`.
|
||||
*/
|
||||
function clearCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
document.cookie = `${DESKTOP_CLIENT_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -50,3 +60,50 @@ describe('useIsDesktopClient', () => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDesktopClientInfo / parseDesktopClientCookie (quick-260918-gza)', () => {
|
||||
it('kodierte Form (wie der Browser sie in document.cookie haelt)', () => {
|
||||
expect(
|
||||
parseDesktopClientCookie('tessera_desktop=1; tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows'),
|
||||
).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
|
||||
});
|
||||
|
||||
it('rohe Form (nicht kodiert)', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|windows')).toEqual({
|
||||
version: '1.2.0',
|
||||
commit: 'a6d1a64',
|
||||
os: 'windows',
|
||||
});
|
||||
});
|
||||
|
||||
it('leerer Commit bleibt leer', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0%7C%7Clinux')).toEqual({
|
||||
version: '1.2.0',
|
||||
commit: '',
|
||||
os: 'linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('ohne Cookie: null', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop=1')).toBeNull();
|
||||
});
|
||||
|
||||
it('unvollstaendige oder leere Pflichtfelder: null', () => {
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=abc')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|x')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=|a6d1a64|linux')).toBeNull();
|
||||
expect(parseDesktopClientCookie('tessera_desktop_client=1.2.0|a6d1a64|')).toBeNull();
|
||||
});
|
||||
|
||||
it('getDesktopClientInfo() liest document.cookie', () => {
|
||||
expect(getDesktopClientInfo()).toBeNull();
|
||||
document.cookie = 'tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows; path=/';
|
||||
expect(getDesktopClientInfo()).toEqual({ version: '1.2.0', commit: 'a6d1a64', os: 'windows' });
|
||||
});
|
||||
|
||||
it('getDesktopClientInfo() ohne document: null', () => {
|
||||
vi.stubGlobal('document', undefined);
|
||||
expect(getDesktopClientInfo()).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,58 @@ import { useEffect, useState } from 'react';
|
||||
*/
|
||||
export const DESKTOP_COOKIE_NAME = 'tessera_desktop';
|
||||
|
||||
/**
|
||||
* Gegenstueck zu `buildDesktopClientCookieValue()` in der Middleware
|
||||
* (quick-260918-gza): dort werden `dv`/`dc`/`dos` mit `${dv}|${dc}|${dos}`
|
||||
* zusammengesetzt und von Next per `encodeURIComponent` serialisiert --
|
||||
* `|` wird `%7C` (gemessen). `document.cookie` liefert deshalb die
|
||||
* KODIERTE Form; `parseDesktopClientCookie()` dekodiert (in try/catch,
|
||||
* falls der Wert bereits roh vorliegt, z. B. direkt gesetzt im Test).
|
||||
*/
|
||||
export const DESKTOP_CLIENT_COOKIE_NAME = 'tessera_desktop_client';
|
||||
|
||||
export interface DesktopClientInfo {
|
||||
version: string;
|
||||
commit: string;
|
||||
os: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst den Wert des Cookies `tessera_desktop_client` aus einem rohen
|
||||
* `document.cookie`-String. `null` bei fehlendem Cookie oder einer Form,
|
||||
* die nicht genau drei durch `|` getrennte Teile mit nicht-leerer Version
|
||||
* und nicht-leerem Betriebssystem ergibt (Commit darf leer sein).
|
||||
*/
|
||||
export function parseDesktopClientCookie(cookieString: string): DesktopClientInfo | null {
|
||||
const entry = cookieString
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${DESKTOP_CLIENT_COOKIE_NAME}=`));
|
||||
if (!entry) return null;
|
||||
|
||||
const raw = entry.slice(DESKTOP_CLIENT_COOKIE_NAME.length + 1);
|
||||
let value = raw;
|
||||
try {
|
||||
value = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Rohwert (z. B. bereits dekodiert im Test) -- unveraendert weiterverwenden.
|
||||
}
|
||||
|
||||
const parts = value.split('|');
|
||||
if (parts.length !== 3) return null;
|
||||
const [version, commit, os] = parts;
|
||||
if (!version || !os) return null;
|
||||
return { version, commit, os };
|
||||
}
|
||||
|
||||
/** `null` ohne `document` (SSR) oder ohne gueltiges Cookie. */
|
||||
export function getDesktopClientInfo(): DesktopClientInfo | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return parseDesktopClientCookie(document.cookie);
|
||||
}
|
||||
|
||||
export function isDesktopClient(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
|
||||
@@ -65,4 +65,52 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
||||
expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1');
|
||||
expect(res.headers.get('x-middleware-next')).toBe('1');
|
||||
});
|
||||
|
||||
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
|
||||
const res = await middleware(req);
|
||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
||||
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
expect(setCookie).toContain('tessera_desktop_client=1.2.0%7Ca6d1a64%7Cwindows');
|
||||
expect(setCookie).toContain('Max-Age=31536000');
|
||||
expect(setCookie).toContain('Path=/');
|
||||
// Kein HttpOnly fuer DIESES Cookie -- der bestehende tessera_desktop
|
||||
// liefert ebenfalls kein HttpOnly, darum genuegt die Wertpruefung oben.
|
||||
});
|
||||
|
||||
it('Test 7 (quick-260918-gza, alter Client): /login?desktop=1 ohne dv/dc/dos setzt kein tessera_desktop_client', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/login?desktop=1');
|
||||
const res = await middleware(req);
|
||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||
expect(res.cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 8 (quick-260918-gza, Bereinigung): ungueltige oder fehlende Werte setzen kein tessera_desktop_client', async () => {
|
||||
const angleBrackets = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0%3Cscript%3E&dc=a6d1a64&dos=windows',
|
||||
);
|
||||
expect((await middleware(angleBrackets)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const spaceInOs = new NextRequest(
|
||||
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=win%20dows',
|
||||
);
|
||||
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
|
||||
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||
|
||||
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
|
||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
||||
});
|
||||
|
||||
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
|
||||
const res = await middleware(req);
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get('location')).toContain('/login');
|
||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,46 @@ const publicRoutes = ['/login', '/reset-password'];
|
||||
|
||||
const DESKTOP_COOKIE = 'tessera_desktop';
|
||||
|
||||
/**
|
||||
* Zweites Cookie (quick-260918-gza): traegt Version, Commit-Stempel und
|
||||
* Betriebssystem der Desktop-App, aus denen der Fehler-melden-Knopf die
|
||||
* Herkunft einer Meldung fuellt (Betreff-Kuerzel `[Desktop/Windows]` bzw.
|
||||
* `[Desktop/Linux]` statt `[Browser]` -- WebView2 und WebKitGTK sehen im
|
||||
* User-Agent sonst wie ein gewoehnlicher Browser aus). `httpOnly: false`
|
||||
* ist Absicht: `getDesktopClientInfo()` (apps/web/src/lib/desktop-client.ts)
|
||||
* liest es aus Seiten-JavaScript, Version/Betriebssystem sind kein Geheimnis
|
||||
* -- dieselbe Vertrauensstufe wie der User-Agent, den jede Seite ohnehin
|
||||
* liest. Next.js serialisiert den Wert mit `encodeURIComponent`
|
||||
* (`|` wird `%7C`, gemessen zur Planungszeit).
|
||||
*/
|
||||
const DESKTOP_CLIENT_COOKIE = 'tessera_desktop_client';
|
||||
|
||||
/** Nur `[A-Za-z0-9.+_-]`, hoechstens 40 Zeichen, mindestens 1 Zeichen. */
|
||||
const DESKTOP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/;
|
||||
/** Leer erlaubt (kein Commit-Stempel im lokalen Bau), sonst hoechstens 40 Hex-/Alnum-Zeichen. */
|
||||
const DESKTOP_COMMIT_RE = /^[A-Za-z0-9]{0,40}$/;
|
||||
/** `std::env::consts::OS`-Werte sind kleingeschrieben ASCII, hoechstens 20 Zeichen. */
|
||||
const DESKTOP_OS_RE = /^[a-z]{1,20}$/;
|
||||
|
||||
/**
|
||||
* Baut den Wert fuer `tessera_desktop_client` aus den Query-Parametern
|
||||
* `dv`/`dc`/`dos` (T-GZA-03: feste Muster, Gesamtlaenge begrenzt). Fehlt
|
||||
* einer der drei Parameter oder passt er nicht auf sein Muster, liefert
|
||||
* die Funktion `null` -- die Middleware setzt das Cookie dann NICHT (ein
|
||||
* alter Desktop-Client ohne diese Parameter bleibt ueber `tessera_desktop`
|
||||
* als Desktop-App erkennbar, nur ohne Details).
|
||||
*/
|
||||
function buildDesktopClientCookieValue(params: URLSearchParams): string | null {
|
||||
const dv = params.get('dv');
|
||||
const dc = params.get('dc');
|
||||
const dos = params.get('dos');
|
||||
if (dv === null || dc === null || dos === null) return null;
|
||||
if (!DESKTOP_VERSION_RE.test(dv) || !DESKTOP_COMMIT_RE.test(dc) || !DESKTOP_OS_RE.test(dos)) {
|
||||
return null;
|
||||
}
|
||||
return `${dv}|${dc}|${dos}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt
|
||||
* (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs,
|
||||
@@ -22,17 +62,25 @@ const DESKTOP_COOKIE = 'tessera_desktop';
|
||||
* Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die
|
||||
* Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird
|
||||
* von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen);
|
||||
* der Wert ist kein Geheimnis.
|
||||
* der Wert ist kein Geheimnis. Seit quick-260918-gza setzt dieselbe Funktion
|
||||
* zusaetzlich das Cookie `tessera_desktop_client` (Version/Commit/OS), sofern
|
||||
* die Anfrage gueltige Werte fuer `dv`/`dc`/`dos` mitbringt -- ohne sie wird
|
||||
* das zweite Cookie weder gesetzt noch geloescht.
|
||||
*/
|
||||
function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse {
|
||||
if (req.nextUrl.searchParams.get('desktop') === '1') {
|
||||
res.cookies.set(DESKTOP_COOKIE, '1', {
|
||||
const cookieOptions = {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
sameSite: 'lax' as const,
|
||||
httpOnly: false,
|
||||
secure: req.nextUrl.protocol === 'https:',
|
||||
});
|
||||
};
|
||||
res.cookies.set(DESKTOP_COOKIE, '1', cookieOptions);
|
||||
const info = buildDesktopClientCookieValue(req.nextUrl.searchParams);
|
||||
if (info !== null) {
|
||||
res.cookies.set(DESKTOP_CLIENT_COOKIE, info, cookieOptions);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user