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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
106
frontend/src/routes/register/page.svelte.spec.ts
Normal file
106
frontend/src/routes/register/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user