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
+171
View File
@@ -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<string, string>).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('<html>') })),
);
const { fetchSessionState } = await import('./auth-actions');
const result = await fetchSessionState();
expect(result).toEqual({ status: 'unavailable' });
expect(cookieDelete).not.toHaveBeenCalled();
});
});
+66
View File
@@ -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<SessionState> {
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.