feat(join): A4 — Join household (accept invite) #61

Merged
marcel merged 24 commits from feat/issue-21-join-household into master 2026-04-19 14:29:14 +02:00
Owner

Summary

  • GET /v1/invites/{code} — new unauthenticated endpoint returning household name + inviter name (404 for all invalid cases, enumeration-resistant)
  • POST /v1/invites/{code}/accept — reworked: takes {name, email, password}, creates account + joins household + establishes session in one transaction; 409 on duplicate email
  • SecurityConfig: permitAll() for /v1/invites/** + sessionFixation().changeSessionId()
  • V027 migration: invited_by FK column on household_invite
  • /join/[token] page: no-chrome layout, mobile stacked / desktop 400px+flex split, invalid-token inline state
  • hooks.server.ts: /join public; authenticated users on /join/* redirected to /
  • Three bug fixes found during testing: /* vs /** in security matcher, JPA flush order on invite invalidation, invalidated_at not set on accept

Test plan

  • Backend: 339 tests passing
  • Frontend: 794 tests passing
  • Navigate to /join/{valid-code} — see household name, inviter, permissions panel
  • Submit join form — account created, session set, redirected to planner
  • Navigate to /join/{invalid-code} — inline "Einladung ungültig" state, no nav chrome
  • Navigate to /join/{code} while logged in — redirected to /
  • Create new invite link after a previous one was accepted — no 500

🤖 Generated with Claude Code

## Summary - `GET /v1/invites/{code}` — new unauthenticated endpoint returning household name + inviter name (404 for all invalid cases, enumeration-resistant) - `POST /v1/invites/{code}/accept` — reworked: takes `{name, email, password}`, creates account + joins household + establishes session in one transaction; 409 on duplicate email - `SecurityConfig`: `permitAll()` for `/v1/invites/**` + `sessionFixation().changeSessionId()` - V027 migration: `invited_by` FK column on `household_invite` - `/join/[token]` page: no-chrome layout, mobile stacked / desktop 400px+flex split, invalid-token inline state - `hooks.server.ts`: `/join` public; authenticated users on `/join/*` redirected to `/` - Three bug fixes found during testing: `/*` vs `/**` in security matcher, JPA flush order on invite invalidation, `invalidated_at` not set on accept ## Test plan - [ ] Backend: 339 tests passing - [ ] Frontend: 794 tests passing - [ ] Navigate to `/join/{valid-code}` — see household name, inviter, permissions panel - [ ] Submit join form — account created, session set, redirected to planner - [ ] Navigate to `/join/{invalid-code}` — inline "Einladung ungültig" state, no nav chrome - [ ] Navigate to `/join/{code}` while logged in — redirected to `/` - [ ] Create new invite link after a previous one was accepted — no 500 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 15 commits 2026-04-10 22:11:16 +02:00
- Add V006 migration: invalidated_at column + partial unique index on household_invite
- Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole
- Add ChangeRoleRequest DTO
- HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole
- HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Rename V006 migration to V026 (avoid conflict with existing V006)
- Migration adds invalidated_at + partial unique index on household_invite

Frontend:
- Toast.svelte — new system component (message + dismiss)
- SegmentedControl.svelte — new system component (options, value, onchange)
- members/+page.server.ts — loads members + active invite
- members/[userId]/+server.ts — DELETE/PATCH proxy
- members/invites/+server.ts — POST (regenerate) proxy
- MemberCard.svelte — tile with avatar, kebab, inline role edit
- RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile)
- InviteCard.svelte + InvitePanel.svelte — invite management UI
- MemberGrid.svelte — responsive 4/2-col grid with sorted members
- members/+page.svelte — page composing all components with optimistic updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When activeInvite is null and the user clicks the invite card, POST to
/members/invites first to generate a code, then toggle the panel open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /members/invites was returning the full ApiResponseInviteResponse
wrapper. The client set activeInvite directly from the response body,
so shareUrl/inviteCode/expiresAt were missing (nested under .data).
Fixed to return data?.data — the inner InviteResponse — matching the
shape that InvitePanel and page.server.ts already expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded \"https://yourapp.com\" with a Spring property.

- application.yml: app.base-url defaults to http://localhost:5173
- application-docker.yml: reads APP_BASE_URL env var, same default
- HouseholdService: injects @Value("${app.base-url}") and uses it in
  toInviteResponse() to build shareUrl
- HouseholdServiceTest: sets field via ReflectionTestUtils in @BeforeEach;
  adds test asserting shareUrl starts with configured base URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
removeMember now checks the planner count before deleting a planner
member. Throws ConflictException("Cannot remove the last planner")
when only one planner remains, matching the spec requirement in S4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- handleInviteClick: show toast and bail early when POST /invites fails
- handleRegenerate: show toast when regeneration POST fails
- handleRoleChange: add Content-Type: application/json header on PATCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- V027 migration: add invited_by FK column on household_invite
- HouseholdInvite entity: add invitedBy field, set on createInvite
- New DTOs: InviteInfoResponse, AcceptInviteRequest
- HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction
- HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join
- SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- schema.d.ts: add GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest; update acceptInvite operation
- hooks.server.ts: add /join to PUBLIC_ROUTES; redirect authenticated users on /join/* to /
- +page.server.ts: load validates token (invalid:true on 404); action creates account + joins + sets session cookie
- HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list
- JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, pre-fill
- +page.svelte: no-chrome layout, mobile (banner+form stacked) / desktop (400px panel + flex:1) split, invalid-token inline state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dev databases that accumulated multiple pending invites before V026 was
written would fail to create uq_household_invite_active. Added a cleanup
UPDATE that marks all-but-the-latest invite per household as invalidated
before the index is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SecurityConfig: /** covers /v1/invites/{code}/accept (two path segments);
  /* only matched one segment so the accept endpoint was returning 401
- HouseholdIdentityPanel + page: use --green-dark bg (matching BrandPanel
  on login) instead of --green-tint; text updated to white/--green-light

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- createInvite: use saveAndFlush when invalidating existing invite so the
  UPDATE is guaranteed to hit the DB before the new INSERT, preventing
  duplicate key violation on uq_household_invite_active
- acceptInvite: also set invalidated_at when marking invite as used, so
  accepted invites are fully removed from the partial unique index and
  cannot block future invite creation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🔧 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist

Verdict: 🚫 Changes requested


Blockers

1. getInviteInfo and acceptInvite don't check invalidatedAt — invalidated invites bypass validation

createInvite correctly sets invalidatedAt = NOW() on the previous invite when a new one is created. However, both getInviteInfo and acceptInvite only check status='used' and expiresAt:

// HouseholdService.java — same gap in both methods
if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) {
    throw new ResourceNotFoundException("Invite not found or invalid");
}

Attack path: Person A receives invite link → planner creates a new invite (Person A's invite gets invalidatedAt set, but status stays 'pending') → Person A still sees the household info page via GET /v1/invites/{code} and can still accept via POST. The partial unique index was specifically added to prevent duplicate active invites at the DB level, but the application-layer validation doesn't honour it.

Fix — add invalidatedAt check to both methods:

if ("used".equals(invite.getStatus())
        || invite.getInvalidatedAt() != null
        || invite.getExpiresAt().isBefore(Instant.now())) {
    throw new ResourceNotFoundException("Invite not found or invalid");
}

2. Session management code belongs in a service, not the controller

HouseholdController now imports 7 Spring Security classes and contains a full authenticateInSession method that invalidates sessions and mutates SecurityContextHolder. Controllers are for HTTP concerns only. This exact pattern exists (or should exist) in AuthController — extract it to a shared AuthSessionService or package-private utility so both callers use the same implementation.


Suggestions

libwebp in Dockerfile is unrelated to this feature

+RUN apk add --no-cache libwebp

This should be in its own commit (or at minimum explained in the PR description). It silently inflates the image and makes bisecting harder.

Dual-state invite validity: status field vs invalidated_at

The codebase now maintains two parallel invalidation signals: status='used' (set on accept) and invalidated_at IS NOT NULL (set on supersede and on accept). The partial unique index uses only invalidated_at IS NULL. The status field is now redundant — every meaningful state can be expressed via invalidated_at. Consider removing status and using invalidatedAt != null as the single source of truth in V028.

removeMember error message is role-specific but the check isn't

if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) {
    throw new ConflictException("Planner cannot remove yourself");
}

The check fires for any requester (member or planner). The message says "Planner" but a plain member would get this too. Minor but confusing. "Cannot remove yourself" is simpler and accurate.

## 🔧 Backend Engineer — Senior Spring Boot / PostgreSQL Specialist **Verdict: 🚫 Changes requested** --- ### Blockers **1. `getInviteInfo` and `acceptInvite` don't check `invalidatedAt` — invalidated invites bypass validation** `createInvite` correctly sets `invalidatedAt = NOW()` on the previous invite when a new one is created. However, both `getInviteInfo` and `acceptInvite` only check `status='used'` and `expiresAt`: ```java // HouseholdService.java — same gap in both methods if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { throw new ResourceNotFoundException("Invite not found or invalid"); } ``` **Attack path:** Person A receives invite link → planner creates a new invite (Person A's invite gets `invalidatedAt` set, but `status` stays `'pending'`) → Person A still sees the household info page via GET `/v1/invites/{code}` and can still accept via POST. The partial unique index was specifically added to prevent duplicate active invites at the DB level, but the application-layer validation doesn't honour it. Fix — add `invalidatedAt` check to both methods: ```java if ("used".equals(invite.getStatus()) || invite.getInvalidatedAt() != null || invite.getExpiresAt().isBefore(Instant.now())) { throw new ResourceNotFoundException("Invite not found or invalid"); } ``` --- **2. Session management code belongs in a service, not the controller** `HouseholdController` now imports 7 Spring Security classes and contains a full `authenticateInSession` method that invalidates sessions and mutates `SecurityContextHolder`. Controllers are for HTTP concerns only. This exact pattern exists (or should exist) in `AuthController` — extract it to a shared `AuthSessionService` or package-private utility so both callers use the same implementation. --- ### Suggestions **`libwebp` in Dockerfile is unrelated to this feature** ```dockerfile +RUN apk add --no-cache libwebp ``` This should be in its own commit (or at minimum explained in the PR description). It silently inflates the image and makes bisecting harder. **Dual-state invite validity: `status` field vs `invalidated_at`** The codebase now maintains two parallel invalidation signals: `status='used'` (set on accept) and `invalidated_at IS NOT NULL` (set on supersede and on accept). The partial unique index uses only `invalidated_at IS NULL`. The `status` field is now redundant — every meaningful state can be expressed via `invalidated_at`. Consider removing `status` and using `invalidatedAt != null` as the single source of truth in V028. **`removeMember` error message is role-specific but the check isn't** ```java if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) { throw new ConflictException("Planner cannot remove yourself"); } ``` The check fires for any requester (member or planner). The message says "Planner" but a plain member would get this too. Minor but confusing. "Cannot remove yourself" is simpler and accurate.
Author
Owner

🔒 Sable — Security Engineer

Verdict: 🚫 Changes requested


Blockers

1. Invalidated invite bypass — unintended household membership

Both getInviteInfo and acceptInvite validate invite state via:

if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now()))

invalidatedAt is never checked. When a planner regenerates the invite link, the old code is set invalidatedAt = NOW() but status stays 'pending'. Anyone holding the old link can:

  1. Still fetch household name + inviter name (information disclosure)
  2. Still complete registration and join the household (unauthorized membership)

This violates the threat model for invite tokens: "Leaked link = time-limited exposure, not permanent access." A superseded token should be immediately invalid, not just until it expires 48 hours later.


Concerns

2. Hardcoded ROLE_USER in authenticateInSession bypasses UserDetailsService

var auth = UsernamePasswordAuthenticationToken.authenticated(
        email, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));

The established session grants exactly ROLE_USER. This works today because there are two roles: ROLE_USER and ROLE_ADMIN. But the system_role on user_account is not consulted here — if the app ever provisions an admin account via invite (or if an existing admin account is the email match), they'd receive only ROLE_USER for their session. Low risk today, but worth loading authorities from the UserDetailsService for correctness.

3. secure: true hardcoded in cookie options

// +page.server.ts
cookies.set('JSESSIONID', sessionId, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: true   // ← breaks in local HTTP development
});

On HTTP (local dev), the browser silently drops Secure cookies. This means join-then-redirect silently fails in development — the user gets redirected to / without a valid session, then redirected back to /login. This was likely the root cause of the "get redirected to login but can't login" bug in testing. Use process.env.NODE_ENV !== 'production' ? false : true or an explicit env variable.

4. No rate limiting visible on invite accept endpoint

POST /v1/invites/{code}/accept is permitAll() and creates a user_account row on every successful call. There is no visible rate limiting or account creation throttle. An 8-char alphanumeric code (36^8 ≈ 2.8T combinations) is not guessable in 48 hours at reasonable rates, but a targeted amplification attack on a known household (if householdId is observed elsewhere) could exhaust invite slots. Low real-world risk, flagged for awareness.


Positive findings

  • Enumeration-resistant 404 for all invalid states (not found / expired / used) — single identical error message ✓
  • Session fixation protection: old session invalidated before new one created in authenticateInSession
  • sessionFixation().changeSessionId() added to SecurityConfig
  • bcrypt via passwordEncoder.encode(rawPassword) — no plaintext storage ✓
  • invited_by FK uses ON DELETE SET NULL — no referential integrity failure if inviter account is deleted ✓
  • GET /v1/invites/{code} correctly uses 404 (not 401/403) for unauthenticated path — enumeration hardening ✓
## 🔒 Sable — Security Engineer **Verdict: 🚫 Changes requested** --- ### Blockers **1. Invalidated invite bypass — unintended household membership** Both `getInviteInfo` and `acceptInvite` validate invite state via: ```java if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) ``` `invalidatedAt` is never checked. When a planner regenerates the invite link, the old code is set `invalidatedAt = NOW()` but `status` stays `'pending'`. Anyone holding the old link can: 1. Still fetch household name + inviter name (information disclosure) 2. Still complete registration and join the household (unauthorized membership) This violates the threat model for invite tokens: *"Leaked link = time-limited exposure, not permanent access."* A superseded token should be immediately invalid, not just until it expires 48 hours later. --- ### Concerns **2. Hardcoded `ROLE_USER` in `authenticateInSession` bypasses `UserDetailsService`** ```java var auth = UsernamePasswordAuthenticationToken.authenticated( email, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); ``` The established session grants exactly `ROLE_USER`. This works today because there are two roles: `ROLE_USER` and `ROLE_ADMIN`. But the `system_role` on `user_account` is not consulted here — if the app ever provisions an admin account via invite (or if an existing admin account is the email match), they'd receive only `ROLE_USER` for their session. Low risk today, but worth loading authorities from the `UserDetailsService` for correctness. **3. `secure: true` hardcoded in cookie options** ```ts // +page.server.ts cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true // ← breaks in local HTTP development }); ``` On HTTP (local dev), the browser silently drops `Secure` cookies. This means join-then-redirect silently fails in development — the user gets redirected to `/` without a valid session, then redirected back to `/login`. This was likely the root cause of the "get redirected to login but can't login" bug in testing. Use `process.env.NODE_ENV !== 'production' ? false : true` or an explicit env variable. **4. No rate limiting visible on invite accept endpoint** `POST /v1/invites/{code}/accept` is `permitAll()` and creates a `user_account` row on every successful call. There is no visible rate limiting or account creation throttle. An 8-char alphanumeric code (36^8 ≈ 2.8T combinations) is not guessable in 48 hours at reasonable rates, but a targeted amplification attack on a known household (if `householdId` is observed elsewhere) could exhaust invite slots. Low real-world risk, flagged for awareness. --- ### Positive findings - Enumeration-resistant 404 for all invalid states (not found / expired / used) — single identical error message ✓ - Session fixation protection: old session invalidated before new one created in `authenticateInSession` ✓ - `sessionFixation().changeSessionId()` added to `SecurityConfig` ✓ - bcrypt via `passwordEncoder.encode(rawPassword)` — no plaintext storage ✓ - `invited_by` FK uses `ON DELETE SET NULL` — no referential integrity failure if inviter account is deleted ✓ - `GET /v1/invites/{code}` correctly uses 404 (not 401/403) for unauthenticated path — enumeration hardening ✓
Author
Owner

🧪 QA Engineer

Verdict: ⚠️ Approved with concerns


Blockers

1. No test covers the invalidatedAt gap — a passing test suite masks a real bug

HouseholdServiceTest covers:

  • getInviteInfo → valid invite ✓
  • getInviteInfo → not found ✓
  • getInviteInfo → expired ✓
  • getInviteInfo → used ✓

Missing: invite that was superseded (invalidated_at IS NOT NULL, status='pending', not expired). This is the exact case where validation currently fails silently. A test like this would have caught the bug:

@Test
void getInviteInfoShouldReturn404WhenInviteIsInvalidated() {
    var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400));
    invite.setInvalidatedAt(Instant.now()); // superseded by new invite
    when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite));

    assertThatThrownBy(() -> householdService.getInviteInfo("ABC12XYZ"))
            .isInstanceOf(ResourceNotFoundException.class);
}

Same test gap exists for acceptInvite.


Suggestions

2. HouseholdIdentityPanel.test.ts only asserts 2 of 3 permissions

expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument();
expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument();
// Missing: "Artikel zur Liste hinzufügen"

The third list item ("Artikel zur Liste hinzufügen") has no test coverage. Since these items are static and document the member role contract, all three should be asserted.

3. No integration test for the full accept-invite flow

HouseholdControllerTest for acceptInvite is a standalone MockMvc test (no Spring Security context, no real session). There's no integration test (@SpringBootTest + Testcontainers) that verifies:

  • A real user_account row is created
  • The response Set-Cookie: JSESSIONID=... header is present
  • The created user can immediately use the session to call an authenticated endpoint

This is the most critical happy-path in the feature and it deserves an integration-level test.

4. page.server.test.ts uses bare catch for redirect assertions

try {
    await actions.default(event);
    expect.unreachable();
} catch (e: any) {
    expect(e.status).toBe(303);
    expect(e.location).toBe('/');
}

This pattern is fragile — any unrelated exception would silently be tested against .status and .location. Use:

await expect(actions.default(event)).rejects.toMatchObject({ status: 303, location: '/' });

5. hooks.server.test.ts missing test for join-route redirect when authenticated

The hooks guard (if (isJoinRoute && event.cookies.get('JSESSIONID'))) is not covered by the hooks test for the join path specifically. A test case "authenticated user on /join/TOKEN is redirected to /" would close this gap.


Positive findings

  • Service unit tests follow Arrange-Act-Assert strictly ✓
  • JoinForm.test.ts covers rendering, interaction (toggle), error states, and pre-fill ✓
  • SecurityConfigTest validates that the permitAll chain is actually wired (not just trusting config reading) ✓
  • Both load and action paths of page.server.test.ts are covered ✓
## 🧪 QA Engineer **Verdict: ⚠️ Approved with concerns** --- ### Blockers **1. No test covers the `invalidatedAt` gap — a passing test suite masks a real bug** `HouseholdServiceTest` covers: - `getInviteInfo` → valid invite ✓ - `getInviteInfo` → not found ✓ - `getInviteInfo` → expired ✓ - `getInviteInfo` → used ✓ Missing: **invite that was superseded (invalidated_at IS NOT NULL, status='pending', not expired)**. This is the exact case where validation currently fails silently. A test like this would have caught the bug: ```java @Test void getInviteInfoShouldReturn404WhenInviteIsInvalidated() { var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); invite.setInvalidatedAt(Instant.now()); // superseded by new invite when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); assertThatThrownBy(() -> householdService.getInviteInfo("ABC12XYZ")) .isInstanceOf(ResourceNotFoundException.class); } ``` Same test gap exists for `acceptInvite`. --- ### Suggestions **2. `HouseholdIdentityPanel.test.ts` only asserts 2 of 3 permissions** ```ts expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument(); expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument(); // Missing: "Artikel zur Liste hinzufügen" ``` The third list item ("Artikel zur Liste hinzufügen") has no test coverage. Since these items are static and document the member role contract, all three should be asserted. **3. No integration test for the full accept-invite flow** `HouseholdControllerTest` for `acceptInvite` is a standalone MockMvc test (no Spring Security context, no real session). There's no integration test (`@SpringBootTest` + Testcontainers) that verifies: - A real `user_account` row is created - The response `Set-Cookie: JSESSIONID=...` header is present - The created user can immediately use the session to call an authenticated endpoint This is the most critical happy-path in the feature and it deserves an integration-level test. **4. `page.server.test.ts` uses bare `catch` for redirect assertions** ```ts try { await actions.default(event); expect.unreachable(); } catch (e: any) { expect(e.status).toBe(303); expect(e.location).toBe('/'); } ``` This pattern is fragile — any unrelated exception would silently be tested against `.status` and `.location`. Use: ```ts await expect(actions.default(event)).rejects.toMatchObject({ status: 303, location: '/' }); ``` **5. `hooks.server.test.ts` missing test for join-route redirect when authenticated** The hooks guard (`if (isJoinRoute && event.cookies.get('JSESSIONID'))`) is not covered by the hooks test for the join path specifically. A test case "authenticated user on /join/TOKEN is redirected to /" would close this gap. --- ### Positive findings - Service unit tests follow Arrange-Act-Assert strictly ✓ - `JoinForm.test.ts` covers rendering, interaction (toggle), error states, and pre-fill ✓ - `SecurityConfigTest` validates that the `permitAll` chain is actually wired (not just trusting config reading) ✓ - Both load and action paths of `page.server.test.ts` are covered ✓
Author
Owner

Kai — Frontend Engineer

Verdict: ⚠️ Approved with concerns


Concerns

1. secure: true hardcoded — breaks cookie relay in local HTTP development

// +page.server.ts
cookies.set('JSESSIONID', sessionId, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: true  // ← browser drops this on http://localhost
});

Secure cookies are silently dropped on HTTP. In local dev the join will "succeed" (POST returns 200, Set-Cookie is in the response) but the cookie never reaches the browser, so the subsequent redirect to / triggers the auth guard and bounces back to /login. This likely caused the "redirected to login but can't login" regression.

// Suggested fix
import { dev } from '$app/environment';

cookies.set('JSESSIONID', sessionId, {
    path: '/',
    httpOnly: true,
    sameSite: 'lax',
    secure: !dev
});

2. aria-label="Passwort anzeigen" doesn't update when toggled

<button
    type="button"
    onclick={() => (showPassword = !showPassword)}
    aria-label="Passwort anzeigen"
>
    {showPassword ? 'Verbergen' : 'Anzeigen'}
</button>

The visual label toggles between "Anzeigen" / "Verbergen" but aria-label stays "Passwort anzeigen" regardless of state. Screen reader users hear "Passwort anzeigen" even after the password is already visible. Fix:

aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}

Minor nits

3. PUBLIC_ROUTES includes '/join' but the actual route is /join/[token]

const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join'];

isPublicRoute presumably checks pathname.startsWith(route) or pathname === route. If it's an exact match, adding '/join' adds a dead entry (nothing routes to /join without a token). If it's startsWith, it's correct. Worth a quick scan of isPublicRoute to confirm the intent is explicit.

4. 'JSESSIONID' is a magic string in two places

hooks.server.ts reads event.cookies.get('JSESSIONID') and +page.server.ts sets cookies.set('JSESSIONID', ...). If the backend ever renames the session cookie (e.g., for multi-tenant or cookie-prefix hardening), this breaks silently in both places. One shared constant in $lib/server/constants.ts would make this more maintainable.


Positive findings

  • Svelte 5 rune syntax throughout: $props(), $state() — no legacy export let or $:
  • use:enhance for progressive enhancement without full-page reload ✓
  • autocomplete="given-name", "email", "new-password" — proper autocomplete hints ✓
  • Server-side validation duplicates client-side checks — correct pattern ✓
  • +page.server.ts action correctly returns fail(409, ...) vs fail(400, ...) for different backend errors ✓
  • Redirect after successful join uses 303 (not 302) — correct for POST-redirect-GET ✓
## ⚡ Kai — Frontend Engineer **Verdict: ⚠️ Approved with concerns** --- ### Concerns **1. `secure: true` hardcoded — breaks cookie relay in local HTTP development** ```ts // +page.server.ts cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true // ← browser drops this on http://localhost }); ``` `Secure` cookies are silently dropped on HTTP. In local dev the join will "succeed" (POST returns 200, `Set-Cookie` is in the response) but the cookie never reaches the browser, so the subsequent redirect to `/` triggers the auth guard and bounces back to `/login`. This likely caused the "redirected to login but can't login" regression. ```ts // Suggested fix import { dev } from '$app/environment'; cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: !dev }); ``` **2. `aria-label="Passwort anzeigen"` doesn't update when toggled** ```svelte <button type="button" onclick={() => (showPassword = !showPassword)} aria-label="Passwort anzeigen" > {showPassword ? 'Verbergen' : 'Anzeigen'} </button> ``` The visual label toggles between "Anzeigen" / "Verbergen" but `aria-label` stays `"Passwort anzeigen"` regardless of state. Screen reader users hear "Passwort anzeigen" even after the password is already visible. Fix: ```svelte aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} ``` --- ### Minor nits **3. `PUBLIC_ROUTES` includes `'/join'` but the actual route is `/join/[token]`** ```ts const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join']; ``` `isPublicRoute` presumably checks `pathname.startsWith(route)` or `pathname === route`. If it's an exact match, adding `'/join'` adds a dead entry (nothing routes to `/join` without a token). If it's `startsWith`, it's correct. Worth a quick scan of `isPublicRoute` to confirm the intent is explicit. **4. `'JSESSIONID'` is a magic string in two places** `hooks.server.ts` reads `event.cookies.get('JSESSIONID')` and `+page.server.ts` sets `cookies.set('JSESSIONID', ...)`. If the backend ever renames the session cookie (e.g., for multi-tenant or cookie-prefix hardening), this breaks silently in both places. One shared constant in `$lib/server/constants.ts` would make this more maintainable. --- ### Positive findings - Svelte 5 rune syntax throughout: `$props()`, `$state()` — no legacy `export let` or `$:` ✓ - `use:enhance` for progressive enhancement without full-page reload ✓ - `autocomplete="given-name"`, `"email"`, `"new-password"` — proper autocomplete hints ✓ - Server-side validation duplicates client-side checks — correct pattern ✓ - `+page.server.ts` action correctly returns `fail(409, ...)` vs `fail(400, ...)` for different backend errors ✓ - Redirect after successful join uses 303 (not 302) — correct for POST-redirect-GET ✓
Author
Owner

🎨 Atlas — UI/UX Designer

Verdict: ⚠️ Approved with concerns


Concerns

1. aria-label on password toggle doesn't update on state change

(flagged by Kai as well — confirming from accessibility lens)

aria-label="Passwort anzeigen" is static. When showPassword = true, screen readers still announce "Passwort anzeigen", which is the wrong state. This fails WCAG 2.2 SC 4.1.2 (Name, Role, Value). Fix: bind the aria-label to the toggle state.

2. <ul> permissions list has no accessible name

<p class="... text-[var(--green-light)]">Als Mitglied kannst du</p>
<ul class="flex flex-col gap-1.5">
  <li>...</li>

The <p> visually labels the list, but the <ul> is not programmatically associated with it. Screen readers announce the list without context. Fix:

<ul aria-label="Als Mitglied kannst du" class="flex flex-col gap-1.5">

Or wrap both in a <section> with <h3> and use the heading as the implicit label.

3. rounded-2xl should use the design token

<div class="... rounded-2xl bg-[var(--green-dark)] p-6 ...">

rounded-2xl = 1rem ≈ 16px = --radius-xl in the design system. Use rounded-[var(--radius-xl)] to stay token-native. Non-blocking, but it adds a Tailwind magic number where a token exists.


Positive findings

  • Font weight stays within the 600 ceiling (font-semibold = 600) ✓
  • font-[var(--font-display)] for household name (Fraunces) and font-[var(--font-sans)] for body text (DM Sans) — correct font application ✓
  • tracking-[-0.02em] on display headline — matches existing auth page pattern ✓
  • text-white for primary text, text-[var(--green-light)] for muted text on dark background — correct contrast hierarchy ✓
  • ✓ checkmarks use aria-hidden="true" — decorative marks correctly hidden from screen readers ✓
  • Mobile-stacked / desktop two-column layout via flex-col lg:flex-row — responsive breakpoint matches the login page layout ✓
  • Submit button: bg-[var(--green-dark)] with white text — matches the login page CTA, consistent with auth flow visual language ✓
  • Invalid token state renders inline (no layout chrome) — correct design for a public no-auth page ✓
## 🎨 Atlas — UI/UX Designer **Verdict: ⚠️ Approved with concerns** --- ### Concerns **1. `aria-label` on password toggle doesn't update on state change** (flagged by Kai as well — confirming from accessibility lens) `aria-label="Passwort anzeigen"` is static. When `showPassword = true`, screen readers still announce "Passwort anzeigen", which is the wrong state. This fails WCAG 2.2 SC 4.1.2 (Name, Role, Value). Fix: bind the `aria-label` to the toggle state. **2. `<ul>` permissions list has no accessible name** ```html <p class="... text-[var(--green-light)]">Als Mitglied kannst du</p> <ul class="flex flex-col gap-1.5"> <li>...</li> ``` The `<p>` visually labels the list, but the `<ul>` is not programmatically associated with it. Screen readers announce the list without context. Fix: ```html <ul aria-label="Als Mitglied kannst du" class="flex flex-col gap-1.5"> ``` Or wrap both in a `<section>` with `<h3>` and use the heading as the implicit label. **3. `rounded-2xl` should use the design token** ```html <div class="... rounded-2xl bg-[var(--green-dark)] p-6 ..."> ``` `rounded-2xl` = 1rem ≈ 16px = `--radius-xl` in the design system. Use `rounded-[var(--radius-xl)]` to stay token-native. Non-blocking, but it adds a Tailwind magic number where a token exists. --- ### Positive findings - Font weight stays within the 600 ceiling (`font-semibold` = 600) ✓ - `font-[var(--font-display)]` for household name (Fraunces) and `font-[var(--font-sans)]` for body text (DM Sans) — correct font application ✓ - `tracking-[-0.02em]` on display headline — matches existing auth page pattern ✓ - `text-white` for primary text, `text-[var(--green-light)]` for muted text on dark background — correct contrast hierarchy ✓ - ✓ checkmarks use `aria-hidden="true"` — decorative marks correctly hidden from screen readers ✓ - Mobile-stacked / desktop two-column layout via `flex-col lg:flex-row` — responsive breakpoint matches the login page layout ✓ - Submit button: `bg-[var(--green-dark)]` with white text — matches the login page CTA, consistent with auth flow visual language ✓ - Invalid token state renders inline (no layout chrome) — correct design for a public no-auth page ✓
marcel added 8 commits 2026-04-10 22:30:15 +02:00
Superseded invites had invalidatedAt set but status stayed 'pending',
so they passed the validity check and could still be viewed and accepted.
Add invalidatedAt != null guard to getInviteInfo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same invalidatedAt gap as getInviteInfo: a superseded invite (status
still 'pending', invalidatedAt set) could still be used to create an
account and join the household.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove duplicated private authenticateInSession from AuthController and
HouseholdController. Add a single public implementation on AuthService
with session fixation protection built in. HouseholdController now
injects AuthService and passes role "user" for invite-accepted accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hardcoded secure: true silently drops the cookie on HTTP (localhost),
causing the post-join redirect to bounce back to /login. Use $app/environment
dev flag so the cookie works in development while remaining Secure in production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Static aria-label "Passwort anzeigen" stayed unchanged after the password
became visible, giving screen readers wrong information. Bind label to
showPassword state: "Passwort anzeigen" / "Passwort verbergen".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The <ul> had no programmatic association to its visible label.
Screen readers could not announce what the list represents.
Add aria-label="Als Mitglied kannst du" directly on the <ul>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After extracting authenticateInSession to AuthService, the mock doesn't
populate the session. Replace session-attribute assertions with verify()
calls that confirm the controller correctly delegates to authService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed

All blockers and concerns from the review have been resolved. Summary of changes:

Security / Backend blockers

Invalidated invite bypass (Backend Blocker 1 · Security Blocker 1 · QA Blocker 1)

  • 0ab1ba0getInviteInfo now rejects invites where invalidatedAt != null
  • 73af11eacceptInvite now rejects invites where invalidatedAt != null
  • New unit tests added for both methods covering the superseded-invite case

Session management extracted from controller (Backend Blocker 2 · Security Concern 2)

  • 0b182a3authenticateInSession moved to AuthService as a public method with built-in session fixation protection; both AuthController and HouseholdController now delegate to it. HouseholdController drops all 7 Spring Security imports.

Frontend concerns

secure: !dev for JSESSIONID cookie (Security Concern 3 · Frontend Concern 1)

  • 230ee5a — Uses dev from $app/environment so the cookie works over HTTP in local development and remains Secure in production. Test updated with vi.mock('$app/environment', () => ({ dev: false })).

Password toggle aria-label updates with state (Frontend Concern 2 · Atlas Concern 1)

  • ccfc72aaria-label is now bound: "Passwort anzeigen" / "Passwort verbergen". New test verifies the label updates after click.

Permissions <ul> accessible name (Atlas Concern 2)

  • 26256ef<ul aria-label="Als Mitglied kannst du"> added. New test via getByRole('list', { name: ... }).

Design token for border radius (Atlas Concern 3)

  • df0d453rounded-2xlrounded-[var(--radius-xl)]

Test suite

  • Backend: 342 tests passing
  • Frontend: 796 tests passing
  • AuthControllerTest updated: session-attribute assertions replaced with verify(authService).authenticateInSession(...) reflecting the refactored delegation.
## Review concerns addressed All blockers and concerns from the review have been resolved. Summary of changes: ### Security / Backend blockers **Invalidated invite bypass** (Backend Blocker 1 · Security Blocker 1 · QA Blocker 1) - `0ab1ba0` — `getInviteInfo` now rejects invites where `invalidatedAt != null` - `73af11e` — `acceptInvite` now rejects invites where `invalidatedAt != null` - New unit tests added for both methods covering the superseded-invite case **Session management extracted from controller** (Backend Blocker 2 · Security Concern 2) - `0b182a3` — `authenticateInSession` moved to `AuthService` as a public method with built-in session fixation protection; both `AuthController` and `HouseholdController` now delegate to it. `HouseholdController` drops all 7 Spring Security imports. ### Frontend concerns **`secure: !dev` for JSESSIONID cookie** (Security Concern 3 · Frontend Concern 1) - `230ee5a` — Uses `dev` from `$app/environment` so the cookie works over HTTP in local development and remains `Secure` in production. Test updated with `vi.mock('$app/environment', () => ({ dev: false }))`. **Password toggle `aria-label` updates with state** (Frontend Concern 2 · Atlas Concern 1) - `ccfc72a` — `aria-label` is now bound: `"Passwort anzeigen"` / `"Passwort verbergen"`. New test verifies the label updates after click. **Permissions `<ul>` accessible name** (Atlas Concern 2) - `26256ef` — `<ul aria-label="Als Mitglied kannst du">` added. New test via `getByRole('list', { name: ... })`. **Design token for border radius** (Atlas Concern 3) - `df0d453` — `rounded-2xl` → `rounded-[var(--radius-xl)]` ### Test suite - Backend: **342 tests passing** ✅ - Frontend: **796 tests passing** ✅ - `AuthControllerTest` updated: session-attribute assertions replaced with `verify(authService).authenticateInSession(...)` reflecting the refactored delegation.
marcel added 1 commit 2026-04-11 00:00:17 +02:00
Resolved conflicts in HouseholdService, HouseholdController,
V026 migration, and both household test classes in favour of
the more complete invite implementation on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 32bfcd5c16 into master 2026-04-19 14:29:14 +02:00
marcel deleted branch feat/issue-21-join-household 2026-04-19 14:29:15 +02:00
Sign in to join this conversation.