From 7a17873046910172d471e3af5f77f800dddc86ea Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:19:40 +0200 Subject: [PATCH] feat(auth): add auth guard in hooks.server.ts with session validation Validates session cookie via GET /v1/auth/me, populates event.locals with benutzer and haushalt, redirects to /login if unauthenticated. Public routes (/login, /register, /invite) bypass auth. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.d.ts | 25 ++++++++ frontend/src/hooks.server.test.ts | 96 +++++++++++++++++++++++++++++++ frontend/src/hooks.server.ts | 40 +++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/hooks.server.test.ts create mode 100644 frontend/src/hooks.server.ts diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..6c9d3a6 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,25 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + interface Error { + meldung?: string; + } + interface Locals { + benutzer?: { + id: string; + name: string; + rolle: 'planer' | 'mitglied'; + }; + haushalt?: { + id: string; + name: string; + }; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts new file mode 100644 index 0000000..f56eadd --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock $env/dynamic/private before importing anything +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +// Mock the apiClient +const mockGet = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet }) +})); + +describe('auth guard (hooks.server.ts handle)', () => { + let handle: any; + + beforeEach(async () => { + mockGet.mockReset(); + const mod = await import('./hooks.server'); + handle = mod.handle; + }); + + function createEvent(pathname: string, cookie?: string) { + const resolve = vi.fn().mockResolvedValue(new Response('ok')); + const event = { + url: new URL(`http://localhost${pathname}`), + cookies: { + get: vi.fn().mockReturnValue(cookie) + }, + locals: {} as any, + fetch: vi.fn() + }; + return { event, resolve }; + } + + it('allows public routes without auth', async () => { + const { event, resolve } = createEvent('/login'); + await handle({ event, resolve }); + expect(resolve).toHaveBeenCalledWith(event); + }); + + it('redirects unauthenticated requests on protected routes', async () => { + const { event, resolve } = createEvent('/planner'); + try { + await handle({ event, resolve }); + // If using SvelteKit redirect, it throws + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/login'); + } + }); + + it('populates event.locals.benutzer on valid session', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + id: '123', + displayName: 'Max', + householdId: 'h1', + householdName: 'Familie Müller', + householdRole: 'planer', + email: 'max@example.com', + systemRole: 'user' + } + }, + error: undefined + }); + + const { event, resolve } = createEvent('/planner', 'valid-session'); + await handle({ event, resolve }); + expect(event.locals.benutzer).toEqual({ + id: '123', + name: 'Max', + rolle: 'planer' + }); + expect(event.locals.haushalt).toEqual({ + id: 'h1', + name: 'Familie Müller' + }); + expect(resolve).toHaveBeenCalledWith(event); + }); + + it('redirects to /login when session validation fails', async () => { + mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } }); + + const { event, resolve } = createEvent('/planner', 'bad-session'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/login'); + } + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..4a690a1 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,40 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; + +const PUBLIC_ROUTES = ['/login', '/register', '/invite']; + +function isPublicRoute(pathname: string): boolean { + return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')); +} + +export const handle: Handle = async ({ event, resolve }) => { + if (isPublicRoute(event.url.pathname)) { + return resolve(event); + } + + const sessionCookie = event.cookies.get('session'); + if (!sessionCookie) { + redirect(302, '/login'); + } + + const api = apiClient(event.fetch); + const { data, error } = await api.GET('/v1/auth/me'); + + if (error || !data?.data) { + redirect(302, '/login'); + } + + const user = data.data; + event.locals.benutzer = { + id: user.id!, + name: user.displayName!, + rolle: user.householdRole as 'planer' | 'mitglied' + }; + event.locals.haushalt = { + id: user.householdId!, + name: user.householdName! + }; + + return resolve(event); +};