fix(invite): address review cycle 2 feedback
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m32s
CI / Unit & Component Tests (pull_request) Failing after 2m31s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 2m46s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m43s

- 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 <main> landmark to register error state
- Add component test suite for register/+page.svelte (11 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 09:30:57 +02:00
parent 9fc4993fca
commit 88012a1193
6 changed files with 140 additions and 9 deletions

View File

@@ -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.16172.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;
}
}

View File

@@ -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<AppUser> register(@RequestBody RegisterRequest request) {
public ResponseEntity<AppUser> register(@Valid @RequestBody RegisterRequest request) {
AppUser user = inviteService.redeemInvite(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -25,7 +25,7 @@ let showPassword = $state(false);
<div class="flex min-h-screen flex-col bg-canvas">
<AuthHeader />
<div class="flex flex-1 items-center justify-center px-4 py-8">
<main class="flex flex-1 items-center justify-center px-4 py-8">
<div class="w-full max-w-sm">
<div class="mb-10 text-center">
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
@@ -53,7 +53,13 @@ let showPassword = $state(false);
<h1 class="mb-2 font-sans text-sm font-bold tracking-widest text-ink uppercase">
{m.register_invalid_code()}
</h1>
<p class="font-serif text-sm text-ink-2">{m.register_invalid_code_desc()}</p>
<p class="mb-6 font-serif text-sm text-ink-2">{m.register_invalid_code_desc()}</p>
<a
href="/login"
class="font-sans text-xs font-bold tracking-widest text-brand-navy/60 uppercase transition-colors hover:text-brand-navy"
>
{m.forgot_password_back_to_login()}
</a>
</div>
{:else}
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
@@ -185,7 +191,7 @@ let showPassword = $state(false);
</div>
{/if}
</div>
</div>
</main>
<div class="py-4 text-center">
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>

View File

@@ -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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[name="email"]');
const firstName = document.querySelector<HTMLInputElement>('input[name="firstName"]');
const lastName = document.querySelector<HTMLInputElement>('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<HTMLInputElement>('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<HTMLAnchorElement>('a[href="/login"]');
expect(link).not.toBeNull();
});
});