From 474d17082b1647a5e837285d28de2b5879d216ea Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 12:28:38 +0200 Subject: [PATCH] feat(quick-260917-gyd): Sitzungswaechter erkennt tote Sitzung, Header leitet ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth-actions.ts: fetchSessionState() unterscheidet tote Sitzung (401/403/leere 200-Antwort, Cookie wird geloescht) von API-Ausfall (5xx/Netzwerkfehler/Nicht-JSON, unavailable ohne Redirect); fetchCurrentUser bleibt unveraendert - header.tsx: Waechter im useEffect leitet bei toter Sitzung per Vollnavigation auf /login?next=… um, bleibt bei API-Ausfall still Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/layout/header.test.tsx | 152 ++++++++++++++++ apps/web/src/components/layout/header.tsx | 61 +++++-- apps/web/src/lib/auth-actions.test.ts | 171 ++++++++++++++++++ apps/web/src/lib/auth-actions.ts | 66 +++++++ 4 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/layout/header.test.tsx create mode 100644 apps/web/src/lib/auth-actions.test.ts diff --git a/apps/web/src/components/layout/header.test.tsx b/apps/web/src/components/layout/header.test.tsx new file mode 100644 index 0000000..298164d --- /dev/null +++ b/apps/web/src/components/layout/header.test.tsx @@ -0,0 +1,152 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +/** + * header.test — Sitzungswaechter im Header (quick-260917-gyd). + * + * next-intl de.json-gestuetzt (Muster: account-settings-form.test.tsx), + * fetchSessionState/logout aus @/lib/auth-actions per vi.hoisted gemockt, + * usePathname per mutable Variable (Muster: sidebar.test.tsx). Der echte + * useAuthStore wird verwendet (nicht gemockt) und in afterEach + * zurueckgesetzt. + */ + +vi.mock('next-intl', async () => { + const messages = (await import('@/messages/de.json')).default as Record< + string, + unknown + >; + const lookup = (path: string): string | undefined => + path + .split('.') + .reduce( + (o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), + messages, + ) as string | undefined; + return { + useTranslations: + (ns?: string) => + (key: string, values?: Record) => { + const raw = lookup(ns ? `${ns}.${key}` : key) ?? key; + return values + ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => + String(values[n] ?? ''), + ) + : raw; + }, + useLocale: () => 'de', + }; +}); + +let mockPathname = '/settings/general/desktop'; +vi.mock('next/navigation', () => ({ + usePathname: () => mockPathname, +})); + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + className, + }: { + children: React.ReactNode; + href: string; + className?: string; + }) => ( + + {children} + + ), +})); + +const { fetchSessionState, logout } = vi.hoisted(() => ({ + fetchSessionState: vi.fn(), + logout: vi.fn(), +})); +vi.mock('@/lib/auth-actions', () => ({ fetchSessionState, logout })); + +vi.mock('@/components/bug-report/bug-report-button', () => ({ + BugReportButton: () => null, +})); +vi.mock('@/components/theme-toggle', () => ({ + ThemeToggle: () => null, +})); +vi.mock('@/components/brand/tessera-logo', () => ({ + TesseraLogo: () => null, +})); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + useAuthStore.setState({ user: null }); + vi.clearAllMocks(); + mockPathname = '/settings/general/desktop'; +}); + +async function renderHeader() { + const { Header } = await import('./header'); + render(
); +} + +describe('Header — Sitzungswaechter (quick-260917-gyd)', () => { + it('Test 1 (authenticated): Store enthaelt den Benutzer, kein Redirect', async () => { + vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' }); + fetchSessionState.mockResolvedValue({ + status: 'authenticated', + user: { + id: 'u1', + username: 'schalli', + displayName: 'Schalli', + role: 'ADMIN', + tenantId: 't1', + hasAvatar: false, + accentColor: null, + }, + }); + + await renderHeader(); + + await waitFor(() => + expect(useAuthStore.getState().user?.username).toBe('schalli'), + ); + expect((window as any).location.href).toBe(''); + }); + + it('Test 2 (unauthenticated auf Unterseite): Vollnavigation auf /login?next=…', async () => { + vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unauthenticated' }); + + await renderHeader(); + + await waitFor(() => + expect((window as any).location.href).toBe( + '/login?next=%2Fsettings%2Fgeneral%2Fdesktop', + ), + ); + }); + + it('Test 3 (unauthenticated auf /): Vollnavigation auf /login ohne next', async () => { + mockPathname = '/'; + vi.stubGlobal('location', { href: '', pathname: '/', search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unauthenticated' }); + + await renderHeader(); + + await waitFor(() => expect((window as any).location.href).toBe('/login')); + }); + + it('Test 4 (unavailable): kein Redirect, Store bleibt leer, Avatar zeigt "?"', async () => { + vi.stubGlobal('location', { href: '', pathname: '/settings/general/desktop', search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unavailable' }); + + await renderHeader(); + + await waitFor(() => expect(fetchSessionState).toHaveBeenCalledTimes(1)); + expect((window as any).location.href).toBe(''); + expect(useAuthStore.getState().user).toBeNull(); + expect( + screen.getByRole('button', { name: 'Benutzermenu' }), + ).toHaveTextContent('?'); + }); +}); diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 50b3f82..c33f8c9 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -6,7 +6,8 @@ import { useTranslations } from 'next-intl'; import { TesseraLogo } from '@/components/brand/tessera-logo'; import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useAuthStore } from '@/lib/stores/auth-store'; -import { fetchCurrentUser, logout } from '@/lib/auth-actions'; +import { fetchSessionState, logout } from '@/lib/auth-actions'; +import { buildNextParam } from '@/lib/safe-next'; import { ThemeToggle } from '@/components/theme-toggle'; import { BugReportButton } from '@/components/bug-report/bug-report-button'; import { usePathname } from 'next/navigation'; @@ -22,23 +23,51 @@ export function Header() { const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; - // Fetch current user on mount to populate auth store + // Redirect-Sperre gegen doppelte Navigation (StrictMode-Doppeleffekt, + // Effekt-Wiederholungen) — quick-260917-gyd. + const redirectedRef = useRef(false); + + // Sitzungswaechter (quick-260917-gyd): Der Header ist der EINZIGE + // Waechter, weil er auf jeder Portalseite genau einmal gerendert wird + // (AppShell im (portal)-Layout); das (auth)-Layout hat keinen Header, + // /login ist oeffentlich (middleware.ts publicRoutes) — Seitenleiste + // und Widget-Aufrufe brauchen deshalb keinen eigenen Umbau, ein + // Doppel-Redirect ist ausgeschlossen. `authenticated` fuellt den Store, + // `unauthenticated` (tote Sitzung, Cookie bereits serverseitig + // geloescht) leitet per Vollnavigation zur Anmeldeseite mit `next` auf + // die aktuelle Seite, `unavailable` (API-Ausfall) bleibt still. useEffect(() => { - if (!user) { - fetchCurrentUser().then((u) => { - if (u) { - setUser({ - id: u.id, - username: u.username, - displayName: u.displayName, - role: u.role, - tenantId: u.tenantId, - hasAvatar: u.hasAvatar, - accentColor: u.accentColor, - }); - } - }); + if (user) { + return; } + fetchSessionState().then((state) => { + if (state.status === 'authenticated') { + const u = state.user; + setUser({ + id: u.id, + username: u.username, + displayName: u.displayName, + role: u.role, + tenantId: u.tenantId, + hasAvatar: u.hasAvatar, + accentColor: u.accentColor, + }); + } else if (state.status === 'unauthenticated') { + if (redirectedRef.current) { + return; + } + redirectedRef.current = true; + const next = buildNextParam( + window.location.pathname, + window.location.search, + ); + window.location.href = next + ? `/login?next=${encodeURIComponent(next)}` + : '/login'; + } + // 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles + // Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen. + }); }, [user, setUser]); // Close dropdown on outside click diff --git a/apps/web/src/lib/auth-actions.test.ts b/apps/web/src/lib/auth-actions.test.ts new file mode 100644 index 0000000..8b18f34 --- /dev/null +++ b/apps/web/src/lib/auth-actions.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * auth-actions.test (fetchSessionState) — Sitzungswaechter (quick-260917-gyd). + * + * next/headers wird gemockt (cookies() liefert ein Promise auf ein Objekt + * mit get/set/delete aus vi.hoisted, Muster: module-access.test.tsx), + * next/navigation ebenfalls (redirect ist hier ungenutzt, muss aber + * importierbar bleiben), fetch per vi.stubGlobal. Die 'use server'- + * Direktive ist unter vitest wirkungslos. + */ + +const { cookieGet, cookieSet, cookieDelete } = vi.hoisted(() => ({ + cookieGet: vi.fn(), + cookieSet: vi.fn(), + cookieDelete: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + cookies: () => + Promise.resolve({ + get: cookieGet, + set: cookieSet, + delete: cookieDelete, + }), +})); + +vi.mock('next/navigation', () => ({ + redirect: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('fetchSessionState', () => { + it('Test 1: kein Cookie -> unauthenticated, kein fetch', async () => { + cookieGet.mockReturnValue(undefined); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unauthenticated' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('Test 2: 200 mit Benutzer -> authenticated, Cookie bleibt, Cookie-Header gesetzt', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + const fetchMock = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('{"id":"u1","username":"schalli"}'), + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result.status).toBe('authenticated'); + if (result.status === 'authenticated') { + expect(result.user.username).toBe('schalli'); + } + expect(cookieDelete).not.toHaveBeenCalled(); + const [, options] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect((options.headers as Record).Cookie).toBe( + 'session=session-abc', + ); + }); + + it('Test 3: Status 401 -> unauthenticated, Cookie genau einmal geloescht', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('') })), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unauthenticated' }); + expect(cookieDelete).toHaveBeenCalledTimes(1); + expect(cookieDelete).toHaveBeenCalledWith('session'); + }); + + it('Test 4: Status 403 -> unauthenticated, Cookie geloescht', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: false, status: 403, text: () => Promise.resolve('') })), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unauthenticated' }); + expect(cookieDelete).toHaveBeenCalledTimes(1); + }); + + it('Test 5: Status 200 mit leerem Body (oder "null") -> unauthenticated, Cookie geloescht', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') })), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result1 = await fetchSessionState(); + expect(result1).toEqual({ status: 'unauthenticated' }); + expect(cookieDelete).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('null') })), + ); + const result2 = await fetchSessionState(); + expect(result2).toEqual({ status: 'unauthenticated' }); + expect(cookieDelete).toHaveBeenCalledTimes(1); + }); + + it('Test 6: Status 500 -> unavailable, Cookie bleibt', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('') })), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unavailable' }); + expect(cookieDelete).not.toHaveBeenCalled(); + }); + + it('Test 7: fetch wirft (Netzwerkfehler) -> unavailable, Cookie bleibt', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('fetch failed'))), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unavailable' }); + expect(cookieDelete).not.toHaveBeenCalled(); + }); + + it('Test 8: Status 200 mit nicht-JSON-Body -> unavailable, Cookie bleibt', async () => { + cookieGet.mockReturnValue({ value: 'session-abc' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') })), + ); + + const { fetchSessionState } = await import('./auth-actions'); + const result = await fetchSessionState(); + + expect(result).toEqual({ status: 'unavailable' }); + expect(cookieDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/auth-actions.ts b/apps/web/src/lib/auth-actions.ts index f2ccdb0..add948f 100644 --- a/apps/web/src/lib/auth-actions.ts +++ b/apps/web/src/lib/auth-actions.ts @@ -236,6 +236,72 @@ export async function updateAccentColorAction( } } +export type SessionState = + | { status: 'authenticated'; user: AuthUser } + | { status: 'unauthenticated' } + | { status: 'unavailable' }; + +/** + * Klassifiziert die aktuelle Sitzung fuer den Header-Waechter + * (quick-260917-gyd). Die Unterscheidung ist load-bearing: nur eine + * nachweislich tote Sitzung (401/403 oder 200 ohne Benutzerobjekt — so + * antwortet NestJS, wenn `AuthService.getMe` bei geloeschtem Benutzer + * `null` liefert, z. B. nach Neuanlage der Datenbank) darf das Cookie + * loeschen und abmelden. Ein API-Ausfall (5xx, Netzwerkfehler, Antwort + * ohne gueltiges JSON) darf KEINE Abmelde-Schleife ausloesen und liefert + * deshalb `unavailable`, ohne das Cookie anzufassen. + * + * `cookieStore.delete()` ist nur in Server Actions/Route Handlers + * erlaubt — deshalb passiert die Loeschung hier und nicht im Header. + */ +export async function fetchSessionState(): Promise { + const cookieStore = await cookies(); + const session = cookieStore.get('session')?.value; + + if (!session) { + return { status: 'unauthenticated' }; + } + + try { + const response = await fetch(`${API_URL}/auth/me`, { + headers: { + Cookie: `session=${session}`, + }, + cache: 'no-store', + }); + + if (response.status === 401 || response.status === 403) { + cookieStore.delete('session'); + return { status: 'unauthenticated' }; + } + + if (!response.ok) { + return { status: 'unavailable' }; + } + + const body = (await response.text()).trim(); + if (body === '' || body === 'null') { + cookieStore.delete('session'); + return { status: 'unauthenticated' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { status: 'unavailable' }; + } + + if (parsed && typeof parsed === 'object' && 'id' in parsed) { + return { status: 'authenticated', user: parsed as AuthUser }; + } + + return { status: 'unavailable' }; + } catch { + return { status: 'unavailable' }; + } +} + /** * Fetch the current authenticated user from the API. * Uses the session cookie for authentication.