feat(quick-260917-gyd): Sitzungswaechter erkennt tote Sitzung, Header leitet ab

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:28:38 +02:00
parent 4b279eac70
commit 474d17082b
4 changed files with 434 additions and 16 deletions
@@ -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<unknown>(
(o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined),
messages,
) as string | undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
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;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
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(<Header />);
}
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('?');
});
});
+45 -16
View File
@@ -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