diff --git a/backend/src/main/java/com/recipeapp/auth/AuthController.java b/backend/src/main/java/com/recipeapp/auth/AuthController.java index 3e5e9ee..d0f6605 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthController.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthController.java @@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; +import java.util.List; @RestController @RequestMapping("/v1/auth") @@ -26,7 +32,7 @@ public class AuthController { @Valid @RequestBody SignupRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.signup(request); - httpRequest.getSession(true); + authenticateInSession(user.email(), "user", httpRequest); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); } @@ -35,17 +41,37 @@ public class AuthController { @Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.login(request); - HttpSession session = httpRequest.getSession(true); - session.setAttribute("user_email", user.email()); + // Session fixation protection: invalidate old session before creating new one + var oldSession = httpRequest.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(user)); } + /** + * Creates an authenticated Spring Security context and stores it in the HTTP session + * so that subsequent requests from the same session are recognised as authenticated. + * We do this manually because we are not using Spring Security's built-in form login. + */ + private void authenticateInSession(String email, String role, HttpServletRequest request) { + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } + @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest httpRequest) { HttpSession session = httpRequest.getSession(false); if (session != null) { session.invalidate(); } + SecurityContextHolder.clearContext(); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index c084836..27c35dc 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -10,8 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; @Configuration @EnableWebSecurity @@ -20,9 +18,9 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) + // CSRF is disabled: SvelteKit is the only client and submits form actions + // server-side, so the browser never calls the backend directly. + .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() diff --git a/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java index d42f40e..f82a582 100644 --- a/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java +++ b/backend/src/test/java/com/recipeapp/auth/AuthControllerTest.java @@ -10,16 +10,20 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.UUID; +import static org.hamcrest.Matchers.notNullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ExtendWith(MockitoExtension.class) class AuthControllerTest { @@ -95,6 +99,40 @@ class AuthControllerTest { .andExpect(jsonPath("$.data.systemRole").value("user")); } + @Test + void signupShouldStoreSecurityContextInSession() throws Exception { + var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah"); + var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah"); + + when(authService.signup(any(SignupRequest.class))).thenReturn(response); + + mockMvc.perform(post("/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(request().sessionAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + notNullValue())); + } + + @Test + void loginShouldStoreSecurityContextInSession() throws Exception { + var request = new LoginRequest("sarah@example.com", "s3cure!Pass"); + var response = UserResponse.withHousehold( + UUID.randomUUID(), "sarah@example.com", "Sarah", + UUID.randomUUID(), "Smith family", "planner", "user"); + + when(authService.login(any(LoginRequest.class))).thenReturn(response); + + mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(request().sessionAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, + notNullValue())); + } + @Test void logoutShouldReturn204() throws Exception { mockMvc.perform(post("/v1/auth/logout")) diff --git a/docker-compose.yml b/docker-compose.yml index 10fa0dc..3b7e8a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,11 +16,11 @@ services: timeout: 3s retries: 5 - app: + backend: build: context: ./backend dockerfile: Dockerfile - container_name: mealprep-app + container_name: mealprep-backend ports: - "8080:8080" environment: @@ -40,9 +40,9 @@ services: ports: - "3000:3000" environment: - BACKEND_URL: http://app:8080 + BACKEND_URL: http://backend:8080 depends_on: - - app + - backend volumes: pgdata: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7df3349..2eea8fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.5.0", "@vitest/ui": "^4.1.2", "jsdom": "^29.0.1", @@ -1809,6 +1810,20 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index ee0cbb9..5733cbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.5.0", "@vitest/ui": "^4.1.2", "jsdom": "^29.0.1", diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 708071e..68fab8a 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); - it.each(['/login', '/login/', '/register', '/invite/abc123'])( + it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])( 'allows public route %s without auth', async (path) => { const { event, resolve } = createEvent(path); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 0f60418..9fbd150 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { apiClient } from '$lib/server/api'; -const PUBLIC_ROUTES = ['/login', '/register', '/invite']; +const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite']; const STATIC_PREFIXES = ['/_app/', '/favicon']; @@ -15,7 +15,7 @@ function isPublicRoute(pathname: string): boolean { function loginRedirect(pathname: string): never { const target = '/login?redirect=' + encodeURIComponent(pathname); - redirect(302, target); + throw redirect(302, target); } export const handle: Handle = async ({ event, resolve }) => { diff --git a/frontend/src/lib/auth/BrandPanel.svelte b/frontend/src/lib/auth/BrandPanel.svelte new file mode 100644 index 0000000..a28d8ab --- /dev/null +++ b/frontend/src/lib/auth/BrandPanel.svelte @@ -0,0 +1,32 @@ + + +
+ + +

+ Mealprep +

+ +

+ Plan meals, eat well, waste less +

+ + +
diff --git a/frontend/src/lib/auth/BrandPanel.test.ts b/frontend/src/lib/auth/BrandPanel.test.ts new file mode 100644 index 0000000..38b9dd3 --- /dev/null +++ b/frontend/src/lib/auth/BrandPanel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import BrandPanel from './BrandPanel.svelte'; + +describe('BrandPanel', () => { + it('renders the app name', () => { + render(BrandPanel); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('renders the tagline', () => { + render(BrandPanel); + expect(screen.getByText('Plan meals, eat well, waste less')).toBeInTheDocument(); + }); + + it('renders the logo emoji', () => { + render(BrandPanel); + expect(screen.getByText('🥗')).toBeInTheDocument(); + }); + + it('renders three feature icons with labels', () => { + render(BrandPanel); + expect(screen.getByText('Plan')).toBeInTheDocument(); + expect(screen.getByText('Cook')).toBeInTheDocument(); + expect(screen.getByText('Shop')).toBeInTheDocument(); + }); + + it('renders feature emojis', () => { + render(BrandPanel); + expect(screen.getByText('📅')).toBeInTheDocument(); + expect(screen.getByText('🍳')).toBeInTheDocument(); + expect(screen.getByText('🛒')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/auth/LoginForm.svelte b/frontend/src/lib/auth/LoginForm.svelte new file mode 100644 index 0000000..166bf51 --- /dev/null +++ b/frontend/src/lib/auth/LoginForm.svelte @@ -0,0 +1,154 @@ + + +
+

+ Willkommen zurück +

+ +

+ Melde dich an, um fortzufahren. +

+ + +
+ + + {#if errors.email} +

+ {errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if errors.password} +

+ {errors.password} +

+ {/if} +
+ + {#if formError} +

+ {formError} +

+ {/if} + + + + + +

+ Noch kein Konto? Registrieren +

+
diff --git a/frontend/src/lib/auth/LoginForm.test.ts b/frontend/src/lib/auth/LoginForm.test.ts new file mode 100644 index 0000000..d338b02 --- /dev/null +++ b/frontend/src/lib/auth/LoginForm.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import LoginForm from './LoginForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('LoginForm', () => { + it('renders email and password fields with correct labels', () => { + render(LoginForm); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders heading and subtitle', () => { + render(LoginForm); + expect(screen.getByText('Willkommen zurück')).toBeInTheDocument(); + expect(screen.getByText('Melde dich an, um fortzufahren.')).toBeInTheDocument(); + }); + + it('renders submit button', () => { + render(LoginForm); + expect(screen.getByRole('button', { name: /anmelden/i })).toBeInTheDocument(); + }); + + it('renders signup link', () => { + render(LoginForm); + const link = screen.getByRole('link', { name: /registrieren/i }); + expect(link).toHaveAttribute('href', '/signup'); + expect(link.className).toContain('text-[var(--green)]'); + expect(link.className).toContain('font-medium'); + }); + + it('submit button uses --green-dark for WCAG AA', () => { + render(LoginForm); + const button = screen.getByRole('button', { name: /anmelden/i }); + expect(button.className).toContain('bg-[var(--green-dark)]'); + }); + + it('password field is initially of type password', () => { + render(LoginForm); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches type', async () => { + const user = userEvent.setup(); + render(LoginForm); + + const input = screen.getByLabelText('Passwort'); + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('inputs have correct autocomplete attributes', () => { + render(LoginForm); + expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email'); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'current-password'); + }); + + it('shows validation error for invalid email on submit', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'notanemail'); + await user.type(screen.getByLabelText('Passwort'), 'password123'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + }); + + it('shows validation error for empty password on submit', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.getByText('Passwort ist erforderlich')).toBeInTheDocument(); + }); + + it('shows no errors when fields are valid', async () => { + const user = userEvent.setup(); + render(LoginForm); + + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.type(screen.getByLabelText('Passwort'), 'password123'); + await user.click(screen.getByRole('button', { name: /anmelden/i })); + + expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument(); + expect(screen.queryByText('Passwort ist erforderlich')).not.toBeInTheDocument(); + }); + + it('displays server-side form error from form prop', () => { + render(LoginForm, { + props: { + form: { + errors: { form: 'E-Mail oder Passwort ist falsch.' }, + email: 'test@example.com' + } + } + }); + expect(screen.getByText('E-Mail oder Passwort ist falsch.')).toBeInTheDocument(); + }); + + it('renders placeholders', () => { + render(LoginForm); + expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Dein Passwort')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/auth/SignupForm.svelte b/frontend/src/lib/auth/SignupForm.svelte new file mode 100644 index 0000000..d04e657 --- /dev/null +++ b/frontend/src/lib/auth/SignupForm.svelte @@ -0,0 +1,190 @@ + + +
+

+ Konto erstellen +

+ +

+ Danach richtest du deinen Haushalt ein. +

+ + +
+ + + {#if errors.displayName} +

+ {errors.displayName} +

+ {/if} +
+ + +
+ + + {#if errors.email} +

+ {errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if errors.password} +

+ {errors.password} +

+ {/if} +
+ + {#if formError} +

+ {formError} +

+ {/if} + + + + + +

+ Du hast bereits ein Konto? Anmelden +

+
diff --git a/frontend/src/lib/auth/SignupForm.test.ts b/frontend/src/lib/auth/SignupForm.test.ts new file mode 100644 index 0000000..897579d --- /dev/null +++ b/frontend/src/lib/auth/SignupForm.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import SignupForm from './SignupForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('SignupForm', () => { + it('renders all form fields with correct labels', () => { + render(SignupForm); + expect(screen.getByLabelText('Dein Name')).toBeInTheDocument(); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders submit button with correct text', () => { + render(SignupForm); + expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument(); + }); + + it('renders login link with correct href and styling', () => { + render(SignupForm); + const link = screen.getByRole('link', { name: /anmelden/i }); + expect(link).toHaveAttribute('href', '/login'); + expect(link.className).toContain('text-[var(--green)]'); + expect(link.className).toContain('font-medium'); + }); + + it('renders heading and subtitle', () => { + render(SignupForm); + expect(screen.getByText('Konto erstellen')).toBeInTheDocument(); + expect(screen.getByText('Danach richtest du deinen Haushalt ein.')).toBeInTheDocument(); + }); + + it('password field is initially of type password', () => { + render(SignupForm); + const input = screen.getByLabelText('Passwort'); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches to text and back', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const input = screen.getByLabelText('Passwort'); + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + + await user.click(toggle); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('shows validation error for empty name on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(email, 'test@example.com'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument(); + }); + + it('shows validation error for invalid email on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'notanemail'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + }); + + it('shows validation error for short password on submit', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'test@example.com'); + await user.type(password, 'short'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); + + it('shows no validation errors when all fields are valid', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const name = screen.getByLabelText('Dein Name'); + const email = screen.getByLabelText('E-Mail'); + const password = screen.getByLabelText('Passwort'); + await user.type(name, 'Sarah'); + await user.type(email, 'test@example.com'); + await user.type(password, 'password123'); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.queryByText('Name ist erforderlich')).not.toBeInTheDocument(); + expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument(); + expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument(); + }); + + it('submit button uses --green-dark for WCAG AA contrast', () => { + render(SignupForm); + const button = screen.getByRole('button', { name: /konto erstellen/i }); + expect(button.className).toContain('bg-[var(--green-dark)]'); + }); + + it('inputs have correct autocomplete attributes', () => { + render(SignupForm); + expect(screen.getByLabelText('Dein Name')).toHaveAttribute('autocomplete', 'name'); + expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email'); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'new-password'); + }); + + it('displays server-side form error when form prop has errors', () => { + render(SignupForm, { + props: { + form: { + errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }, + displayName: 'Sarah', + email: 'sarah@example.com' + } + } + }); + expect( + screen.getByText('Registrierung fehlgeschlagen. Bitte versuche es erneut.') + ).toBeInTheDocument(); + }); + + it('displays server-side field errors from form prop', () => { + render(SignupForm, { + props: { + form: { + errors: { email: 'Ungültige E-Mail-Adresse' }, + displayName: 'Sarah', + email: 'bad' + } + } + }); + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + }); + + it('shows all three validation errors when form submitted empty', async () => { + const user = userEvent.setup(); + render(SignupForm); + + const submit = screen.getByRole('button', { name: /konto erstellen/i }); + await user.click(submit); + + expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument(); + expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument(); + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); + + it.each([ + { length: 7, shouldFail: true }, + { length: 8, shouldFail: false } + ])('password with $length chars $shouldFail ? fails : passes validation', async ({ length, shouldFail }) => { + const user = userEvent.setup(); + render(SignupForm); + + await user.type(screen.getByLabelText('Dein Name'), 'Sarah'); + await user.type(screen.getByLabelText('E-Mail'), 'test@example.com'); + await user.type(screen.getByLabelText('Passwort'), 'a'.repeat(length)); + await user.click(screen.getByRole('button', { name: /konto erstellen/i })); + + if (shouldFail) { + expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument(); + } else { + expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument(); + } + }); + + it('renders placeholders on inputs', () => { + render(SignupForm); + expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Mindestens 8 Zeichen')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte index d90949a..a54cfdc 100644 --- a/frontend/src/routes/(public)/+layout.svelte +++ b/frontend/src/routes/(public)/+layout.svelte @@ -2,11 +2,4 @@ let { children } = $props(); -
- -
- {@render children()} -
-
+{@render children()} 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..452e13f --- /dev/null +++ b/frontend/src/routes/(public)/login/+page.server.ts @@ -0,0 +1,47 @@ +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, cookies }) => { + 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, response } = await api.POST('/v1/auth/login', { + body: { email, password } + }); + + if (error) { + return fail(400, { + errors: { form: 'E-Mail oder Passwort ist falsch.' }, + email + }); + } + + const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true }); + } + + const raw = url.searchParams.get('redirect'); + const redirectTo = raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/planner'; + throw redirect(303, redirectTo); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte index 7b4a440..dc77604 100644 --- a/frontend/src/routes/(public)/login/+page.svelte +++ b/frontend/src/routes/(public)/login/+page.svelte @@ -1,2 +1,20 @@ -

Anmelden

-

Login-Formular folgt.

+ + + + Anmelden — Mealprep + + + +
+ +
+
+ +
+
+
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..212aeaa --- /dev/null +++ b/frontend/src/routes/(public)/login/page.server.test.ts @@ -0,0 +1,175 @@ +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; + } + + function mockSuccess() { + return { + data: { data: { id: '123' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }; + } + + it('calls POST /v1/auth/login with form data', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + 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(mockSuccess()); + + 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(mockSuccess()); + + 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('falls back to /planner when ?redirect= is an absolute URL', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createEvent( + { email: 'sarah@example.com', password: 'password123' }, + '?redirect=https%3A%2F%2Fevil.com' + )); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/planner'); + } + }); + + it('falls back to /planner when ?redirect= is a protocol-relative URL', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createEvent( + { email: 'sarah@example.com', password: 'password123' }, + '?redirect=%2F%2Fevil.com' + )); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/planner'); + } + }); + + it('sets JSESSIONID cookie on successful login', async () => { + mockPost.mockResolvedValue({ + data: { data: { id: '123' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } } + }); + + const event = createEvent({ email: 'sarah@example.com', password: 'password123' }); + try { + await actions.default(event); + } catch { + // redirect throws + } + + expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'abc123', expect.objectContaining({ path: '/', secure: true })); + }); + + 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.'); + }); +}); diff --git a/frontend/src/routes/(public)/login/page.test.ts b/frontend/src/routes/(public)/login/page.test.ts new file mode 100644 index 0000000..29485a0 --- /dev/null +++ b/frontend/src/routes/(public)/login/page.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Page from './+page.svelte'; + +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/login') }) + }; +}); + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('login page', () => { + it('renders the login form', () => { + render(Page); + expect(screen.getByText('Willkommen zurück')).toBeInTheDocument(); + }); + + it('renders the brand panel', () => { + render(Page); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('sets the page title', () => { + render(Page); + expect(document.title).toBe('Anmelden — Mealprep'); + }); + + it('does not render any navigation chrome', () => { + render(Page); + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + expect(screen.queryByText('Planer')).not.toBeInTheDocument(); + expect(screen.queryByText('Rezepte')).not.toBeInTheDocument(); + }); + + it('renders a link to the signup page', () => { + render(Page); + const link = screen.getByRole('link', { name: /registrieren/i }); + expect(link).toHaveAttribute('href', '/signup'); + }); +}); diff --git a/frontend/src/routes/(public)/signup/+page.server.ts b/frontend/src/routes/(public)/signup/+page.server.ts new file mode 100644 index 0000000..1c4f8ed --- /dev/null +++ b/frontend/src/routes/(public)/signup/+page.server.ts @@ -0,0 +1,51 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions } from './$types'; + +export const actions = { + default: async ({ request, fetch, cookies }) => { + const formData = await request.formData(); + const displayName = (formData.get('displayName') ?? '').toString().trim(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + if (!displayName) { + errors.displayName = 'Name ist erforderlich'; + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (password.length < 8) { + errors.password = 'Mindestens 8 Zeichen'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, displayName, email }); + } + + const api = apiClient(fetch); + const { error, response } = await api.POST('/v1/auth/signup', { + body: { displayName, email, password } + }); + + if (error) { + return fail(400, { + errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }, + displayName, + email + }); + } + + const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true }); + } + + throw redirect(303, '/household/setup'); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/signup/+page.svelte b/frontend/src/routes/(public)/signup/+page.svelte new file mode 100644 index 0000000..dd9dda0 --- /dev/null +++ b/frontend/src/routes/(public)/signup/+page.svelte @@ -0,0 +1,20 @@ + + + + Konto erstellen — Mealprep + + + +
+ +
+
+ +
+
+
diff --git a/frontend/src/routes/(public)/signup/page.server.test.ts b/frontend/src/routes/(public)/signup/page.server.test.ts new file mode 100644 index 0000000..451497b --- /dev/null +++ b/frontend/src/routes/(public)/signup/page.server.test.ts @@ -0,0 +1,161 @@ +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('signup form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createRequest(formData: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + function mockSuccess() { + return { + data: { data: { id: '123' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }; + } + + it('calls POST /v1/auth/signup with form data', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/auth/signup', { + body: { + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + } + }); + }); + + it('redirects to /household/setup on success', async () => { + mockPost.mockResolvedValue(mockSuccess()); + + try { + await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/household/setup'); + } + }); + + it('sets JSESSIONID cookie on successful signup', async () => { + mockPost.mockResolvedValue({ + data: { data: { id: '123' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=xyz789; Path=/; HttpOnly') } } + }); + + const event = createRequest({ displayName: 'Sarah', email: 'sarah@example.com', password: 'password123' }); + try { + await actions.default(event); + } catch { + // redirect throws + } + + expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'xyz789', expect.objectContaining({ path: '/', secure: true })); + }); + + it('rejects empty displayName with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: '', + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.displayName).toBe('Name ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects invalid email with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'notanemail', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects short password with validation error', async () => { + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'short' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBe('Mindestens 8 Zeichen'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('handles missing form fields without crashing', async () => { + const fd = new FormData(); + const event = { + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + + const result = await actions.default(event); + expect(result.status).toBe(400); + expect(result.data.errors.displayName).toBe('Name ist erforderlich'); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('returns fail with error message on API error', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 409, message: 'Email already registered' } + }); + + const result = await actions.default(createRequest({ + displayName: 'Sarah', + email: 'sarah@example.com', + password: 'password123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.'); + }); +}); diff --git a/frontend/src/routes/(public)/signup/page.test.ts b/frontend/src/routes/(public)/signup/page.test.ts new file mode 100644 index 0000000..c8fde4d --- /dev/null +++ b/frontend/src/routes/(public)/signup/page.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Page from './+page.svelte'; + +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/signup') }) + }; +}); + +describe('signup page', () => { + it('renders the signup form', () => { + render(Page); + expect(screen.getByText('Konto erstellen')).toBeInTheDocument(); + }); + + it('renders the brand panel', () => { + render(Page); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('sets the page title', () => { + render(Page); + expect(document.title).toBe('Konto erstellen — Mealprep'); + }); + + it('does not render any navigation chrome', () => { + render(Page); + // No nav element should exist + expect(screen.queryByRole('navigation')).not.toBeInTheDocument(); + // No app shell nav links + expect(screen.queryByText('Planer')).not.toBeInTheDocument(); + expect(screen.queryByText('Rezepte')).not.toBeInTheDocument(); + expect(screen.queryByText('Einkauf')).not.toBeInTheDocument(); + expect(screen.queryByText('Einstellungen')).not.toBeInTheDocument(); + }); +});