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) {
|
private boolean isTrustedProxy(String ip) {
|
||||||
return ip.equals("127.0.0.1") || ip.equals("::1")
|
if (ip.equals("127.0.0.1") || ip.equals("::1") || ip.startsWith("10.") || ip.startsWith("192.168.")) {
|
||||||
|| ip.startsWith("10.")
|
return true;
|
||||||
|| ip.startsWith("172.")
|
}
|
||||||
|| ip.startsWith("192.168.");
|
// 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;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
|
import org.raddatz.familienarchiv.dto.InvitePrefillDTO;
|
||||||
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
import org.raddatz.familienarchiv.dto.RegisterRequest;
|
||||||
@@ -50,7 +51,7 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AppUser> register(@RequestBody RegisterRequest request) {
|
public ResponseEntity<AppUser> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
AppUser user = inviteService.redeemInvite(request);
|
AppUser user = inviteService.redeemInvite(request);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(user);
|
return ResponseEntity.status(HttpStatus.CREATED).body(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class RegisterRequest {
|
public class RegisterRequest {
|
||||||
|
@NotBlank
|
||||||
private String code;
|
private String code;
|
||||||
|
@NotBlank
|
||||||
|
@Email
|
||||||
private String email;
|
private String email;
|
||||||
|
@NotBlank
|
||||||
private String password;
|
private String password;
|
||||||
private String firstName;
|
private String firstName;
|
||||||
private String lastName;
|
private String lastName;
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ CREATE INDEX idx_invite_tokens_code ON invite_tokens(code);
|
|||||||
|
|
||||||
CREATE TABLE invite_token_group_ids (
|
CREATE TABLE invite_token_group_ids (
|
||||||
invite_token_id UUID NOT NULL REFERENCES invite_tokens(id),
|
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)
|
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">
|
<div class="flex min-h-screen flex-col bg-canvas">
|
||||||
<AuthHeader />
|
<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="w-full max-w-sm">
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
<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">
|
<h1 class="mb-2 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||||
{m.register_invalid_code()}
|
{m.register_invalid_code()}
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||||
@@ -185,7 +191,7 @@ let showPassword = $state(false);
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<p class="font-sans text-xs tracking-widest text-ink-3 uppercase">Familienarchiv</p>
|
<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