From 88012a11936c998d601c59aa6359610ee0805c1b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 09:30:57 +0200 Subject: [PATCH] fix(invite): address review cycle 2 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Narrow isTrustedProxy to RFC 1918 172.16-31.x.x (was 172.x.x.x) - Add @Valid/@NotBlank/@Email to RegisterRequest and @Valid to AuthController - Add FK constraint on invite_token_group_ids.group_id → user_groups(id) - Add back-to-login link and
landmark to register error state - Add component test suite for register/+page.svelte (11 tests) Co-Authored-By: Claude Sonnet 4.6 --- .../config/RateLimitInterceptor.java | 20 +++- .../controller/AuthController.java | 3 +- .../familienarchiv/dto/RegisterRequest.java | 6 + .../db/migration/V45__add_invite_tokens.sql | 2 +- frontend/src/routes/register/+page.svelte | 12 +- .../src/routes/register/page.svelte.spec.ts | 106 ++++++++++++++++++ 6 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 frontend/src/routes/register/page.svelte.spec.ts diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java index 9c495406..ed53494c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java @@ -49,9 +49,21 @@ public class RateLimitInterceptor implements HandlerInterceptor { } private boolean isTrustedProxy(String ip) { - return ip.equals("127.0.0.1") || ip.equals("::1") - || ip.startsWith("10.") - || ip.startsWith("172.") - || ip.startsWith("192.168."); + if (ip.equals("127.0.0.1") || ip.equals("::1") || ip.startsWith("10.") || ip.startsWith("192.168.")) { + return true; + } + // Only RFC 1918 172.16.0.0/12 (172.16–172.31), not all of 172.x + if (ip.startsWith("172.")) { + String[] parts = ip.split("\\."); + if (parts.length >= 2) { + try { + int second = Integer.parseInt(parts[1]); + return second >= 16 && second <= 31; + } catch (NumberFormatException ignored) { + return false; + } + } + } + return false; } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java index 146300db..7ad891b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java @@ -1,5 +1,6 @@ package org.raddatz.familienarchiv.controller; +import jakarta.validation.Valid; import org.raddatz.familienarchiv.dto.ForgotPasswordRequest; import org.raddatz.familienarchiv.dto.InvitePrefillDTO; import org.raddatz.familienarchiv.dto.RegisterRequest; @@ -50,7 +51,7 @@ public class AuthController { } @PostMapping("/register") - public ResponseEntity register(@RequestBody RegisterRequest request) { + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { AppUser user = inviteService.redeemInvite(request); return ResponseEntity.status(HttpStatus.CREATED).body(user); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java index 3cb23b71..9401943a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java @@ -1,11 +1,17 @@ package org.raddatz.familienarchiv.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data public class RegisterRequest { + @NotBlank private String code; + @NotBlank + @Email private String email; + @NotBlank private String password; private String firstName; private String lastName; diff --git a/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql b/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql index 882c6c87..cb70df4c 100644 --- a/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql +++ b/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql @@ -17,6 +17,6 @@ CREATE INDEX idx_invite_tokens_code ON invite_tokens(code); CREATE TABLE invite_token_group_ids ( invite_token_id UUID NOT NULL REFERENCES invite_tokens(id), - group_id UUID NOT NULL, + group_id UUID NOT NULL REFERENCES user_groups(id), PRIMARY KEY (invite_token_id, group_id) ); diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte index c13c3cc9..7724377c 100644 --- a/frontend/src/routes/register/+page.svelte +++ b/frontend/src/routes/register/+page.svelte @@ -25,7 +25,7 @@ let showPassword = $state(false);

Familienarchiv

diff --git a/frontend/src/routes/register/page.svelte.spec.ts b/frontend/src/routes/register/page.svelte.spec.ts new file mode 100644 index 00000000..7ee8bd34 --- /dev/null +++ b/frontend/src/routes/register/page.svelte.spec.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import RegisterPage from './+page.svelte'; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +afterEach(cleanup); + +const validData = { + code: 'ABCDE12345', + prefill: null, + codeError: null +}; + +const prefillData = { + code: 'ABCDE12345', + prefill: { firstName: 'Max', lastName: 'Muster', email: 'max@test.com' }, + codeError: null +}; + +const errorData = { + code: null, + prefill: null, + codeError: 'INVITE_NOT_FOUND' +}; + +describe('Register page – valid code', () => { + it('renders the heading', async () => { + render(RegisterPage, { data: validData }); + await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + it('renders the email input', async () => { + render(RegisterPage, { data: validData }); + await tick(); + const input = document.querySelector('input[name="email"]'); + expect(input).not.toBeNull(); + expect(input?.type).toBe('email'); + expect(input?.required).toBe(true); + }); + + it('renders the password input', async () => { + render(RegisterPage, { data: validData }); + await tick(); + const input = document.querySelector('input[name="password"]'); + expect(input).not.toBeNull(); + expect(input?.required).toBe(true); + }); + + it('renders the submit button', async () => { + render(RegisterPage, { data: validData }); + await expect.element(page.getByRole('button', { name: 'Konto erstellen' })).toBeInTheDocument(); + }); + + it('prefills fields from invite data', async () => { + render(RegisterPage, { data: prefillData }); + await tick(); + const email = document.querySelector('input[name="email"]'); + const firstName = document.querySelector('input[name="firstName"]'); + const lastName = document.querySelector('input[name="lastName"]'); + expect(email?.value).toBe('max@test.com'); + expect(firstName?.value).toBe('Max'); + expect(lastName?.value).toBe('Muster'); + }); + + it('has a hidden code input', async () => { + render(RegisterPage, { data: validData }); + await tick(); + const hidden = document.querySelector('input[name="code"][type="hidden"]'); + expect(hidden).not.toBeNull(); + expect(hidden?.value).toBe('ABCDE12345'); + }); + + it('shows form error when action returns error', async () => { + render(RegisterPage, { data: validData, form: { error: 'INVITE_REVOKED' } }); + await tick(); + expect(document.querySelector('.text-red-600')).not.toBeNull(); + }); + + it('has main landmark', async () => { + render(RegisterPage, { data: validData }); + await tick(); + expect(document.querySelector('main')).not.toBeNull(); + }); +}); + +describe('Register page – error state', () => { + it('renders the error card when codeError is set', async () => { + render(RegisterPage, { data: errorData }); + await tick(); + expect(document.querySelector('form')).toBeNull(); + }); + + it('shows a back-to-login link in error state', async () => { + render(RegisterPage, { data: errorData }); + await expect.element(page.getByRole('link', { name: /login/i })).toBeInTheDocument(); + }); + + it('back-to-login link points to /login', async () => { + render(RegisterPage, { data: errorData }); + await tick(); + const link = document.querySelector('a[href="/login"]'); + expect(link).not.toBeNull(); + }); +});