From 73acc0c638162f69e591205d569c4e3fa90b0351 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 16:20:02 +0200 Subject: [PATCH] feat(auth): add login server action with validation and redirect POSTs to /v1/auth/login, validates email/password server-side, redirects to ?redirect param or /planner on success. Returns generic error on bad credentials to prevent enumeration. Co-Authored-By: Claude Opus 4.6 --- .../src/routes/(public)/login/+page.server.ts | 41 ++++++ .../routes/(public)/login/page.server.test.ts | 120 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 frontend/src/routes/(public)/login/+page.server.ts create mode 100644 frontend/src/routes/(public)/login/page.server.test.ts diff --git a/frontend/src/routes/(public)/login/+page.server.ts b/frontend/src/routes/(public)/login/+page.server.ts new file mode 100644 index 0000000..dd6912b --- /dev/null +++ b/frontend/src/routes/(public)/login/+page.server.ts @@ -0,0 +1,41 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions } from './$types'; + +export const actions = { + default: async ({ request, url, fetch }) => { + const formData = await request.formData(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (!password) { + errors.password = 'Passwort ist erforderlich'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, email }); + } + + const api = apiClient(fetch); + const { error } = await api.POST('/v1/auth/login', { + body: { email, password } + }); + + if (error) { + return fail(400, { + errors: { form: 'E-Mail oder Passwort ist falsch.' }, + email + }); + } + + const redirectTo = url.searchParams.get('redirect') || '/planner'; + redirect(303, redirectTo); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/login/page.server.test.ts b/frontend/src/routes/(public)/login/page.server.test.ts new file mode 100644 index 0000000..a3f636e --- /dev/null +++ b/frontend/src/routes/(public)/login/page.server.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ POST: mockPost }) +})); + +describe('login form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createEvent(formData: Record, searchParams = '') { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + request: { formData: () => Promise.resolve(fd) }, + url: new URL(`http://localhost/login${searchParams}`), + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + it('calls POST /v1/auth/login with form data', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/auth/login', { + body: { + email: 'sarah@example.com', + password: 'password123' + } + }); + }); + + it('redirects to /planner on success by default', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/planner'); + } + }); + + it('redirects to ?redirect param when present', async () => { + mockPost.mockResolvedValue({ data: { data: { id: '123' } }, error: undefined }); + + try { + await actions.default(createEvent( + { email: 'sarah@example.com', password: 'password123' }, + '?redirect=%2Frecipes%2Fabc' + )); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/recipes/abc'); + } + }); + + it('rejects empty email with validation error', async () => { + const result = await actions.default(createEvent({ + email: '', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects empty password with validation error', async () => { + const result = await actions.default(createEvent({ + email: 'sarah@example.com', + password: '' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBe('Passwort ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('returns fail with form error on API error', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 401, message: 'Invalid credentials' } + }); + + const result = await actions.default(createEvent({ + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBe('E-Mail oder Passwort ist falsch.'); + }); +});