refactor(admin): migrate invites + users to createApiClient (#465) #623

Open
marcel wants to merge 6 commits from feat/issue-465-event-fetch-refactor into main
Owner

Summary

  • Replaces all manual fetch(${apiUrl}/api/...) calls in admin/invites/+page.server.ts (load, create, revoke) with createApiClient(fetch) using typed GET/POST/DELETE — removes the env import entirely
  • Replaces the inline fetch('/api/users/...') + try/catch JSON parsing in admin/users/[id]/+page.server.ts update action with createApiClient(fetch).PUT(...) per project pattern
  • Adds missing Vitest tests for create/revoke invites actions and a new page.server.test.ts for the users update action (5 tests)
  • Fixes 86 pre-existing test failures across 14 *.server.spec.ts files caused by @sentry/sveltekit wrapping load functions and reading event.request.method/event.url.pathname on mock events that lacked those fields
  • Fixes the test:coverage semicolon (;&&) so a failing server run no longer gets silently swallowed by the subsequent client run — CI was reporting green despite 86 failures
  • Clarifies in CONTRIBUTING.md that multipart uploads must use event.fetch, never global fetch

Spring Sessions note: The auth bypass described in the issue was already resolved by #523/#524 — event.fetch was already used everywhere in these files. This PR completes the remaining cleanup: typed client adoption, test coverage, and surfacing hidden test failures.

Test plan

  • npx vitest run --project=server — 576/576 passing (was 490/576 on main)
  • grep -rE "fetch\(\?${.*API|fetch(env." frontend/src/routes— no matches outside oflogin/` (intentional)
  • npm run check — all errors are pre-existing on main, none in touched files
  • npm run lint — clean

Closes #465

🤖 Generated with Claude Code

## Summary - Replaces all manual `fetch(${apiUrl}/api/...)` calls in `admin/invites/+page.server.ts` (load, create, revoke) with `createApiClient(fetch)` using typed GET/POST/DELETE — removes the `env` import entirely - Replaces the inline `fetch('/api/users/...')` + try/catch JSON parsing in `admin/users/[id]/+page.server.ts` `update` action with `createApiClient(fetch).PUT(...)` per project pattern - Adds missing Vitest tests for `create`/`revoke` invites actions and a new `page.server.test.ts` for the users update action (5 tests) - **Fixes 86 pre-existing test failures** across 14 `*.server.spec.ts` files caused by `@sentry/sveltekit` wrapping load functions and reading `event.request.method`/`event.url.pathname` on mock events that lacked those fields - **Fixes the `test:coverage` semicolon** (`;` → `&&`) so a failing server run no longer gets silently swallowed by the subsequent client run — CI was reporting green despite 86 failures - Clarifies in `CONTRIBUTING.md` that multipart uploads must use `event.fetch`, never global `fetch` **Spring Sessions note**: The auth bypass described in the issue was already resolved by #523/#524 — `event.fetch` was already used everywhere in these files. This PR completes the remaining cleanup: typed client adoption, test coverage, and surfacing hidden test failures. ## Test plan - [x] `npx vitest run --project=server` — 576/576 passing (was 490/576 on main) - [x] `grep -rE "fetch\(\`?\${.*API|fetch\(env\." frontend/src/routes` — no matches outside of `login/` (intentional) - [x] `npm run check` — all errors are pre-existing on `main`, none in touched files - [x] `npm run lint` — clean Closes #465 🤖 Generated with [Claude Code](https://claude.ai/code)
marcel added 3 commits 2026-05-19 10:14:59 +02:00
Replace manual fetch(${apiUrl}/api/...) calls in load, create, and revoke
with createApiClient(fetch) so auth injection is handled by handleFetch
and the typed API contract is enforced at compile time.

Also fix pre-existing load test failures caused by Sentry's load wrapper
reading event.request.method (add request to the mock event object).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace fetch('/api/users/${id}', { method: 'PUT', ... }) + inline JSON
error parsing with createApiClient(fetch).PUT('/api/users/{id}', ...) and
the standard result.error cast pattern.

Also fix pre-existing Sentry mock event failures in layout.server.spec.ts
by adding request and url to the test event object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(contributing): clarify event.fetch required even for multipart
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
741b2231f2
The multipart note previously said "use raw fetch" which was misread
as "global fetch is acceptable". Clarify that event.fetch must always
be used — the typed client is bypassed for multipart, but handleFetch
still needs to inject the session cookie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-05-19 10:27:34 +02:00
fix(tests): add missing Sentry mock event fields across 14 spec files; fix test:coverage semicolon
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
07415a5b2b
`@sentry/sveltekit` wraps load functions and reads `event.request.method` and
`event.url.pathname`. Mock events that omitted `request` or `url` threw
`TypeError: Cannot read properties of undefined` on every invocation, silently
masking 86 test failures on main.

Two root causes fixed:
- Added `request: new Request(...)` (and `url: new URL(...)` where absent) to
  all mock event objects in 14 `*.server.spec.ts` files
- Changed `;` to `&&` in the `test:coverage` npm script so a failing server
  run propagates its exit code instead of being swallowed by the client run

All 576 server-project tests now pass.

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

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

This is clean, disciplined refactoring. The createApiClient migration is consistent, error extraction follows the project pattern exactly (result.error as unknown as { code?: string }), and removing env.API_INTERNAL_URL from load functions/actions is the right call — those import paths were fragile.

What I looked for and found correct

  • Error handling: All three !result.response.ok branches use the correct idiom (not result.error, which breaks when the spec has no error response defined). The fallback to 'INTERNAL_ERROR' is consistent across load, create, and revoke.
  • as unknown as InviteListItem in invites: the comment explains why — the spec marks shareableUrl optional but the backend always populates it. Acceptable cast with documented intent.
  • body as components['schemas']['AdminUpdateUserRequest'] in users update: commented clearly — null fields are intentional signals to clear data. The cast satisfies the optional-only spec type without lying.
  • test:coverage semicolon fix (; → &&): this was a genuine CI bug — a failing server run was silently swallowed, making CI appear green with 86 hidden failures. Excellent catch; fixing it in the same PR that adds the tests is the right scope.
  • CONTRIBUTING.md clarification: the new sentence about event.fetch (not global fetch) for multipart uploads is precise and directly actionable.

Suggestions (non-blocking)

  • as any in test files: The 14 spec files use as any to avoid typing the full RequestEvent shape. Given the test-only scope and the // eslint-disable-next-line comments, this is fine, but a shared makeEvent(url: string) factory could eliminate most of the repetition if the test suite grows further.
  • page.server.test.ts vs page.server.spec.ts: The new users file uses .test.ts while the rest of the suite uses .spec.ts. No functional difference with Vitest but worth aligning for discoverability.
  • invites load() test: The test asserts that mockFetch was called twice and checks the URL strings, but doesn't verify the status query param is forwarded (i.e. status=active in the URL). Low-priority gap since it's a pre-existing test that was updated rather than a new one.

Excellent work. The PR description is thorough — the 86 pre-existing failures context is exactly what reviewers need.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** This is clean, disciplined refactoring. The `createApiClient` migration is consistent, error extraction follows the project pattern exactly (`result.error as unknown as { code?: string }`), and removing `env.API_INTERNAL_URL` from load functions/actions is the right call — those import paths were fragile. ### What I looked for and found correct - **Error handling**: All three `!result.response.ok` branches use the correct idiom (not `result.error`, which breaks when the spec has no error response defined). The fallback to `'INTERNAL_ERROR'` is consistent across `load`, `create`, and `revoke`. - **`as unknown as InviteListItem`** in invites: the comment explains why — the spec marks `shareableUrl` optional but the backend always populates it. Acceptable cast with documented intent. - **`body as components['schemas']['AdminUpdateUserRequest']`** in users update: commented clearly — null fields are intentional signals to clear data. The cast satisfies the optional-only spec type without lying. - **`test:coverage` semicolon fix** (`; → &&`): this was a genuine CI bug — a failing server run was silently swallowed, making CI appear green with 86 hidden failures. Excellent catch; fixing it in the same PR that adds the tests is the right scope. - **`CONTRIBUTING.md` clarification**: the new sentence about `event.fetch` (not global `fetch`) for multipart uploads is precise and directly actionable. ### Suggestions (non-blocking) - **`as any` in test files**: The 14 spec files use `as any` to avoid typing the full `RequestEvent` shape. Given the test-only scope and the `// eslint-disable-next-line` comments, this is fine, but a shared `makeEvent(url: string)` factory could eliminate most of the repetition if the test suite grows further. - **`page.server.test.ts` vs `page.server.spec.ts`**: The new users file uses `.test.ts` while the rest of the suite uses `.spec.ts`. No functional difference with Vitest but worth aligning for discoverability. - **`invites load()` test**: The test asserts that `mockFetch` was called twice and checks the URL strings, but doesn't verify the `status` query param is forwarded (i.e. `status=active` in the URL). Low-priority gap since it's a pre-existing test that was updated rather than a new one. Excellent work. The PR description is thorough — the 86 pre-existing failures context is exactly what reviewers need.
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

Architecturally clean. This PR enforces a consistent data-access boundary — all server-side API calls flow through createApiClient(fetch) instead of raw fetch with ad-hoc URL string construction. That's the right direction for the SvelteKit server layer.

What I checked

Layer boundary compliance: +page.server.ts files calling createApiClient is the correct pattern per the project architecture. No controller-level logic is leaking into components. No direct repository access anywhere in scope.

env.API_INTERNAL_URL removal: This is a meaningful improvement. The old pattern scattered env.API_INTERNAL_URL || 'http://localhost:8080' fallbacks across multiple files. Moving this knowledge into createApiClient centralizes the concern. The architecture is now: all server-side fetch calls → one shared client → one URL resolution point.

test:coverage semicolon fix: This is the most architecturally significant fix in the PR, even though it looks trivial. ; between CI steps means failures are silent. && means failures are surfaced. This was a systemic quality-gate defect.

CONTRIBUTING.md clarification: The multipart/event.fetch note is accurate and correctly positioned. This is architecture documentation in the right place.

No documentation update required

The CLAUDE.md architecture doc table checks:

  • No new SvelteKit routes added → no route table update needed
  • No new backend packages → no package table update needed
  • No new API client patterns that aren't already documented
  • No new infrastructure components

The createApiClient pattern is already the documented approach. This PR migrates remaining files to that pattern — no new architectural decisions, no ADR needed.

One observation (non-blocking)

The as unknown as InviteListItem double-cast in invites/+page.server.ts is a symptom of a mismatch between the OpenAPI-generated types and the actual backend contract (shareableUrl optional in spec, always populated in practice). The comment documents this correctly. The real fix belongs in the backend's @Schema(requiredMode = REQUIRED) annotation on that field — but that's a separate PR and a backend concern, not something to block this refactor on.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** Architecturally clean. This PR enforces a consistent data-access boundary — all server-side API calls flow through `createApiClient(fetch)` instead of raw `fetch` with ad-hoc URL string construction. That's the right direction for the SvelteKit server layer. ### What I checked **Layer boundary compliance**: `+page.server.ts` files calling `createApiClient` is the correct pattern per the project architecture. No controller-level logic is leaking into components. No direct repository access anywhere in scope. **`env.API_INTERNAL_URL` removal**: This is a meaningful improvement. The old pattern scattered `env.API_INTERNAL_URL || 'http://localhost:8080'` fallbacks across multiple files. Moving this knowledge into `createApiClient` centralizes the concern. The architecture is now: *all server-side fetch calls → one shared client → one URL resolution point*. **`test:coverage` semicolon fix**: This is the most architecturally significant fix in the PR, even though it looks trivial. `;` between CI steps means failures are silent. `&&` means failures are surfaced. This was a systemic quality-gate defect. **CONTRIBUTING.md clarification**: The multipart/`event.fetch` note is accurate and correctly positioned. This is architecture documentation in the right place. ### No documentation update required The CLAUDE.md architecture doc table checks: - No new SvelteKit routes added → no route table update needed - No new backend packages → no package table update needed - No new API client patterns that aren't already documented - No new infrastructure components The `createApiClient` pattern is already the documented approach. This PR migrates remaining files to that pattern — no new architectural decisions, no ADR needed. ### One observation (non-blocking) The `as unknown as InviteListItem` double-cast in `invites/+page.server.ts` is a symptom of a mismatch between the OpenAPI-generated types and the actual backend contract (`shareableUrl` optional in spec, always populated in practice). The comment documents this correctly. The real fix belongs in the backend's `@Schema(requiredMode = REQUIRED)` annotation on that field — but that's a separate PR and a backend concern, not something to block this refactor on.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No new vulnerabilities introduced. The migration improves the security posture in one concrete way and removes a class of latent risk.

What improved

env.API_INTERNAL_URL || 'http://localhost:8080' removed from action handlers: The old pattern had a silent fallback to localhost:8080 in every action. If API_INTERNAL_URL was misconfigured in production, the app would silently fall back to localhost with no error. While this is more of a reliability concern than a security one, it does eliminate a class of SSRF-adjacent misconfiguration: a misconfigured internal URL could redirect server-side requests to an unexpected host. The new createApiClient centralizes this to a single point.

Error code extraction: (result.error as unknown as { code?: string })?.code ?? 'INTERNAL_ERROR' is correct. No raw exception messages, no stack traces, no internal details are forwarded to the client. The getErrorMessage(code) call in the users update action maps to i18n strings, which is the right pattern.

Items I checked and found clean

  • No CSRF regression: The PR doesn't touch SecurityConfig or CSRF configuration. event.fetch correctly inherits the session cookie — this is the documented safe pattern.
  • No @CrossOrigin changes: No new CORS exposure.
  • No actuator exposure changes: Out of scope, no changes.
  • No raw user input reflected: Error messages go through getErrorMessage(), not raw reflection.
  • No new permissions: The invite and user admin endpoints existed before; this PR only changes how the client calls them, not who can call them.
  • id in DELETE path: api.DELETE('/api/invites/{id}', { params: { path: { id } } }) — the typed client handles URL encoding. No injection risk.

One observation (non-blocking)

The invites/+page.server.ts load function uses event.url.searchParams.get('status') to pass a status query param to the backend without validation. The status value goes directly into api.GET('/api/invites', { params: { query: { status } } }). Since this is a server-side call with the typed client and the backend validates the enum, the injection surface is minimal — but a quick allowlist (['active', 'revoked', 'expired'].includes(status)) before forwarding would be a clean defense-in-depth improvement. Not a blocker.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No new vulnerabilities introduced. The migration improves the security posture in one concrete way and removes a class of latent risk. ### What improved **`env.API_INTERNAL_URL || 'http://localhost:8080'` removed from action handlers**: The old pattern had a silent fallback to `localhost:8080` in every action. If `API_INTERNAL_URL` was misconfigured in production, the app would silently fall back to localhost with no error. While this is more of a reliability concern than a security one, it does eliminate a class of SSRF-adjacent misconfiguration: a misconfigured internal URL could redirect server-side requests to an unexpected host. The new `createApiClient` centralizes this to a single point. **Error code extraction**: `(result.error as unknown as { code?: string })?.code ?? 'INTERNAL_ERROR'` is correct. No raw exception messages, no stack traces, no internal details are forwarded to the client. The `getErrorMessage(code)` call in the users update action maps to i18n strings, which is the right pattern. ### Items I checked and found clean - **No CSRF regression**: The PR doesn't touch `SecurityConfig` or CSRF configuration. `event.fetch` correctly inherits the session cookie — this is the documented safe pattern. - **No `@CrossOrigin` changes**: No new CORS exposure. - **No actuator exposure changes**: Out of scope, no changes. - **No raw user input reflected**: Error messages go through `getErrorMessage()`, not raw reflection. - **No new permissions**: The invite and user admin endpoints existed before; this PR only changes how the client calls them, not who can call them. - **`id` in DELETE path**: `api.DELETE('/api/invites/{id}', { params: { path: { id } } })` — the typed client handles URL encoding. No injection risk. ### One observation (non-blocking) The `invites/+page.server.ts` load function uses `event.url.searchParams.get('status')` to pass a `status` query param to the backend without validation. The `status` value goes directly into `api.GET('/api/invites', { params: { query: { status } } })`. Since this is a server-side call with the typed client and the backend validates the enum, the injection surface is minimal — but a quick allowlist (`['active', 'revoked', 'expired'].includes(status)`) before forwarding would be a clean defense-in-depth improvement. Not a blocker.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

The test work in this PR is solid and the test:coverage semicolon fix is a critical quality-gate repair. 86 hidden test failures in CI is a significant regression — surfacing them is exactly the right move.

What I verified

New tests — admin/invites/page.server.test.ts:

  • create action: 3 new tests — success, backend error with code, backend error without code (fallback). All three cases covered.
  • revoke action: 3 new tests — HTTP method + URL assertion, success return shape, error forwarding.
  • Test structure follows Arrange-Act-Assert clearly. Factory function mockResponse() is reused across describe blocks.

New tests — admin/users/[id]/page.server.test.ts:

  • 5 tests for the update action: method/URL assertion, success, 403 error, 500 without code field, password mismatch guard (short-circuit without backend call). All meaningful boundary conditions are covered.
  • makeUpdateRequest() factory function is a good pattern — overridable defaults, readable in test bodies.

test:coverage semicolon → && fix: This is the most important fix in the PR from a QA perspective. With ;, a server-side test run failure exited non-zero but the shell proceeded to the client run, which passed — so CI showed green. With &&, the failure is properly propagated. This was masking 86 failures across 14 spec files.

14 existing spec files updated with request/url fields: The @sentry/sveltekit wrapper introduced a event.request.method / event.url.pathname access that broke mocks that only provided { fetch, url }. Adding request: new Request(...) to each mock event is the correct fix. All 14 files updated consistently.

Observations (non-blocking)

.test.ts vs .spec.ts naming: The new page.server.test.ts files use .test.ts while the rest of the suite uses .spec.ts. Vitest runs both, so no functional issue — but if the Vitest config has a glob that only picks up one extension, this could cause a miss. Worth verifying the include pattern in vitest.config.ts.

status query param forwarding not tested in invites load(): The test calls event('active') but doesn't assert that mockFetch was called with ?status=active in the URL. The existing test only asserts that /api/invites and /api/groups were called. Low-priority gap — happy path coverage could include this.

admin/users/[id]/page.server.test.ts missing load() tests: The new test file covers only the update action. The load() function (which fetches user + groups in parallel and enforces ADMIN permission) has no server-side unit tests. The existing layout.server.spec.ts covers the layout but the page-level load is untested. Recommend a follow-up issue.

page.server.spec.ts vs page.server.test.ts: The invites file keeps the original name page.server.test.ts (Gitea shows it existed before). The inconsistency predates this PR, so no action required here.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** The test work in this PR is solid and the `test:coverage` semicolon fix is a critical quality-gate repair. 86 hidden test failures in CI is a significant regression — surfacing them is exactly the right move. ### What I verified **New tests — `admin/invites/page.server.test.ts`**: - `create` action: 3 new tests — success, backend error with code, backend error without code (fallback). All three cases covered. ✅ - `revoke` action: 3 new tests — HTTP method + URL assertion, success return shape, error forwarding. ✅ - Test structure follows Arrange-Act-Assert clearly. Factory function `mockResponse()` is reused across describe blocks. ✅ **New tests — `admin/users/[id]/page.server.test.ts`**: - 5 tests for the `update` action: method/URL assertion, success, 403 error, 500 without code field, password mismatch guard (short-circuit without backend call). All meaningful boundary conditions are covered. ✅ - `makeUpdateRequest()` factory function is a good pattern — overridable defaults, readable in test bodies. ✅ **`test:coverage` semicolon → `&&` fix**: This is the most important fix in the PR from a QA perspective. With `;`, a server-side test run failure exited non-zero but the shell proceeded to the client run, which passed — so CI showed green. With `&&`, the failure is properly propagated. This was masking 86 failures across 14 spec files. ✅ **14 existing spec files updated with `request`/`url` fields**: The `@sentry/sveltekit` wrapper introduced a `event.request.method` / `event.url.pathname` access that broke mocks that only provided `{ fetch, url }`. Adding `request: new Request(...)` to each mock event is the correct fix. All 14 files updated consistently. ✅ ### Observations (non-blocking) **`.test.ts` vs `.spec.ts` naming**: The new `page.server.test.ts` files use `.test.ts` while the rest of the suite uses `.spec.ts`. Vitest runs both, so no functional issue — but if the Vitest config has a glob that only picks up one extension, this could cause a miss. Worth verifying the `include` pattern in `vitest.config.ts`. **`status` query param forwarding not tested in invites `load()`**: The test calls `event('active')` but doesn't assert that `mockFetch` was called with `?status=active` in the URL. The existing test only asserts that `/api/invites` and `/api/groups` were called. Low-priority gap — happy path coverage could include this. **`admin/users/[id]/page.server.test.ts` missing `load()` tests**: The new test file covers only the `update` action. The `load()` function (which fetches user + groups in parallel and enforces ADMIN permission) has no server-side unit tests. The existing `layout.server.spec.ts` covers the layout but the page-level load is untested. Recommend a follow-up issue. **`page.server.spec.ts` vs `page.server.test.ts`**: The invites file keeps the original name `page.server.test.ts` (Gitea shows it existed before). The inconsistency predates this PR, so no action required here.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

This PR doesn't touch Docker Compose, CI workflows, or infrastructure configuration — which is the right scope for a frontend refactor. The one DevOps-adjacent change is the test:coverage script fix, and it's correct.

What I checked

package.jsontest:coverage script:

- "test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
+ "test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",

The ; allowed the pipeline to continue even on non-zero exit from the server run. With &&, a failing server coverage run stops the script and exits non-zero — CI correctly fails. This is standard shell composition; the fix is minimal and correct.

No CI workflow changes: No .gitea/workflows/*.yml files touched. No new jobs, no new artifact uploads, no dependency changes in the CI pipeline. Clean.

No infrastructure changes: No Docker Compose modifications, no new services, no port exposures, no secret additions. env.API_INTERNAL_URL removal is a frontend concern that was never in the Compose file's service definitions.

CONTRIBUTING.mdevent.fetch documentation: Correctly documents that server-side multipart uploads must use event.fetch, not global fetch. This is relevant to platform engineering because the session cookie injection depends on handleFetch being in the chain — a global fetch would silently bypass auth on multipart routes. Good documentation catch.

One observation (non-blocking)

The PR description mentions 576/576 passing with npx vitest run --project=server. If CI runs this script, the coverage threshold gates should now catch regressions that were previously invisible. Worth verifying that the CI workflow invokes test:coverage (not just test) to pick up the && fix in the automated pipeline.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** This PR doesn't touch Docker Compose, CI workflows, or infrastructure configuration — which is the right scope for a frontend refactor. The one DevOps-adjacent change is the `test:coverage` script fix, and it's correct. ### What I checked **`package.json` — `test:coverage` script**: ```diff - "test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage", + "test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage", ``` The `;` allowed the pipeline to continue even on non-zero exit from the server run. With `&&`, a failing server coverage run stops the script and exits non-zero — CI correctly fails. This is standard shell composition; the fix is minimal and correct. **No CI workflow changes**: No `.gitea/workflows/*.yml` files touched. No new jobs, no new artifact uploads, no dependency changes in the CI pipeline. Clean. **No infrastructure changes**: No Docker Compose modifications, no new services, no port exposures, no secret additions. `env.API_INTERNAL_URL` removal is a frontend concern that was never in the Compose file's service definitions. **`CONTRIBUTING.md` — `event.fetch` documentation**: Correctly documents that server-side multipart uploads must use `event.fetch`, not global `fetch`. This is relevant to platform engineering because the session cookie injection depends on `handleFetch` being in the chain — a global `fetch` would silently bypass auth on multipart routes. Good documentation catch. ### One observation (non-blocking) The PR description mentions 576/576 passing with `npx vitest run --project=server`. If CI runs this script, the coverage threshold gates should now catch regressions that were previously invisible. Worth verifying that the CI workflow invokes `test:coverage` (not just `test`) to pick up the `&&` fix in the automated pipeline.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

This PR maps cleanly to the stated intent of issue #465. No scope creep detected. The additional work (86 hidden test failures, coverage script fix) is directly enabling work — it surfaces the actual health of what was being tested.

Requirements traceability

Issue #465 intent (as stated in PR description): Replace manual fetch() calls with createApiClient in admin pages; remove env imports; adopt typed endpoints.

Delivered:

  • admin/invites/+page.server.ts: load, create, revoke all migrated to typed client
  • admin/users/[id]/+page.server.ts: update action migrated; load and delete were already using createApiClient
  • env import removed from invites file entirely
  • CONTRIBUTING.md updated to clarify the event.fetch constraint for multipart uploads

Scope boundary respected:
The PR description explicitly notes: "The auth bypass described in the issue was already resolved by #523/#524 — event.fetch was already used everywhere in these files." This is correct scoping — the PR delivers the remaining cleanup without re-doing closed work.

Bundled fixes (enabling work, not scope creep):

  • 86 pre-existing test failures fixed: these were test infrastructure regressions caused by @sentry/sveltekit wrapping. They blocked validating the correctness of the refactor. Fixing them in the same PR is the right call.
  • test:coverage semicolon fix: this was silently masking the 86 failures. Fixing it here ensures the test suite is trustworthy for future work.

One gap to note (non-blocking)

The admin/users/[id]/+page.server.ts load() function has no new unit test coverage added (see Sara's comment). The update action is now well-tested. A follow-up issue to add load() tests for this page would complete the coverage story for this route.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** This PR maps cleanly to the stated intent of issue #465. No scope creep detected. The additional work (86 hidden test failures, coverage script fix) is directly enabling work — it surfaces the actual health of what was being tested. ### Requirements traceability **Issue #465 intent** (as stated in PR description): Replace manual `fetch()` calls with `createApiClient` in admin pages; remove `env` imports; adopt typed endpoints. **Delivered**: - ✅ `admin/invites/+page.server.ts`: `load`, `create`, `revoke` all migrated to typed client - ✅ `admin/users/[id]/+page.server.ts`: `update` action migrated; `load` and `delete` were already using `createApiClient` - ✅ `env` import removed from invites file entirely - ✅ CONTRIBUTING.md updated to clarify the `event.fetch` constraint for multipart uploads **Scope boundary respected**: The PR description explicitly notes: *"The auth bypass described in the issue was already resolved by #523/#524 — `event.fetch` was already used everywhere in these files."* This is correct scoping — the PR delivers the remaining cleanup without re-doing closed work. **Bundled fixes (enabling work, not scope creep)**: - 86 pre-existing test failures fixed: these were test infrastructure regressions caused by `@sentry/sveltekit` wrapping. They blocked validating the correctness of the refactor. Fixing them in the same PR is the right call. - `test:coverage` semicolon fix: this was silently masking the 86 failures. Fixing it here ensures the test suite is trustworthy for future work. ### One gap to note (non-blocking) The `admin/users/[id]/+page.server.ts` `load()` function has no new unit test coverage added (see Sara's comment). The `update` action is now well-tested. A follow-up issue to add `load()` tests for this page would complete the coverage story for this route.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: Approved

This PR is pure server-side refactoring — no Svelte components, no CSS, no markup, no i18n strings, no user-visible changes. No UX or accessibility surface area was modified.

What I checked

User-visible behavior: The load, create, revoke, and update server actions now call createApiClient instead of raw fetch. From the user's perspective, the invite management page and the user edit page behave identically — same fields, same error messages, same redirect behavior.

Error message handling: The users update action correctly routes through getErrorMessage(code), which feeds Paraglide i18n strings to the UI. Error messages remain user-facing localized strings, not raw backend output.

CONTRIBUTING.md change: Developer documentation only — no user-facing copy changed.

No component changes: No .svelte files touched. Touch targets, contrast ratios, focus management, and ARIA labels are all out of scope for this PR.

Nothing to flag from a UX or accessibility perspective.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This PR is pure server-side refactoring — no Svelte components, no CSS, no markup, no i18n strings, no user-visible changes. No UX or accessibility surface area was modified. ### What I checked **User-visible behavior**: The `load`, `create`, `revoke`, and `update` server actions now call `createApiClient` instead of raw `fetch`. From the user's perspective, the invite management page and the user edit page behave identically — same fields, same error messages, same redirect behavior. **Error message handling**: The users `update` action correctly routes through `getErrorMessage(code)`, which feeds Paraglide i18n strings to the UI. Error messages remain user-facing localized strings, not raw backend output. ✅ **`CONTRIBUTING.md` change**: Developer documentation only — no user-facing copy changed. **No component changes**: No `.svelte` files touched. Touch targets, contrast ratios, focus management, and ARIA labels are all out of scope for this PR. Nothing to flag from a UX or accessibility perspective.
marcel added 1 commit 2026-05-19 10:39:31 +02:00
fix(admin): address PR #623 review feedback
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m29s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
d1c6ae67c1
- Add load() unit tests for admin/users/[id] (permission gate, 404, success)
- Rename .test.ts → .spec.ts for consistency with rest of suite
- Add @Schema(requiredMode=REQUIRED) to InviteListItem.shareableUrl
- Add client-side allowlist for invite status query param

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

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

This is a solid, principled refactor. The migration to createApiClient is applied consistently, error handling follows the established project pattern, and the test additions are thorough. A few nits worth addressing.

Blockers

None.

Suggestions

+page.server.ts (invites) — as unknown as InviteListItem double cast is a smell
The comment in the code explains why (shareableUrl is now @Schema(requiredMode = REQUIRED) on the backend but the generated type might be lagging), and the backend fix in InviteListItemDTO.java is correct. The double cast as unknown as InviteListItem[] at line ~58 should be a temporary measure — consider leaving a // TODO: remove cast after next npm run generate:api note, or confirm the types have been regenerated and collapse this. Right now the comment says "keeping the required shape here avoids null-guarding" but the generated type still requires the cast, which signals the regeneration may not have happened yet. Low priority but worth confirming.

admin/users/[id]/+page.server.tsbody as components['schemas']['AdminUpdateUserRequest'] cast
The comment explains the intent ("backend treats null as clear this field") but the spec type marks those fields as optional, not null-accepting. If the backend's actual behaviour diverges from the spec here, the spec should be corrected upstream, not cast around. Worth filing a follow-up issue.

Test file naming — page.server.spec.ts vs page.server.test.ts
The rename from page.server.test.tspage.server.spec.ts in the invites folder is good for consistency. However the test plan claims 576/576 passing — it would be worth checking that no other *.server.test.ts files still exist un-renamed to avoid confusion about the naming convention.

VALID_STATUSES whitelist in invites load — nice addition
The VALID_STATUSES guard with proper type narrowing ((typeof VALID_STATUSES)[number]) is exactly the right pattern. The fallback to 'active' (lowercase) is intentional but note that the backend likely accepts uppercase 'ACTIVE' — worth confirming the filter actually works end-to-end, since a mismatch would silently default to the wrong status with no error.

test:coverage semicolon fix — important
The ;&& fix is significant. Silent test failures in CI are a real risk. Good catch.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** This is a solid, principled refactor. The migration to `createApiClient` is applied consistently, error handling follows the established project pattern, and the test additions are thorough. A few nits worth addressing. ### Blockers None. ### Suggestions **`+page.server.ts` (invites) — `as unknown as InviteListItem` double cast is a smell** The comment in the code explains why (`shareableUrl` is now `@Schema(requiredMode = REQUIRED)` on the backend but the generated type might be lagging), and the backend fix in `InviteListItemDTO.java` is correct. The double cast `as unknown as InviteListItem[]` at line ~58 should be a temporary measure — consider leaving a `// TODO: remove cast after next npm run generate:api` note, or confirm the types have been regenerated and collapse this. Right now the comment says "keeping the required shape here avoids null-guarding" but the generated type still requires the cast, which signals the regeneration may not have happened yet. Low priority but worth confirming. **`admin/users/[id]/+page.server.ts` — `body as components['schemas']['AdminUpdateUserRequest']` cast** The comment explains the intent ("backend treats null as clear this field") but the spec type marks those fields as optional, not `null`-accepting. If the backend's actual behaviour diverges from the spec here, the spec should be corrected upstream, not cast around. Worth filing a follow-up issue. **Test file naming — `page.server.spec.ts` vs `page.server.test.ts`** The rename from `page.server.test.ts` → `page.server.spec.ts` in the invites folder is good for consistency. However the test plan claims 576/576 passing — it would be worth checking that no other `*.server.test.ts` files still exist un-renamed to avoid confusion about the naming convention. **`VALID_STATUSES` whitelist in invites load — nice addition** The `VALID_STATUSES` guard with proper type narrowing (`(typeof VALID_STATUSES)[number]`) is exactly the right pattern. The fallback to `'active'` (lowercase) is intentional but note that the backend likely accepts uppercase `'ACTIVE'` — worth confirming the filter actually works end-to-end, since a mismatch would silently default to the wrong status with no error. **`test:coverage` semicolon fix — important** The `;` → `&&` fix is significant. Silent test failures in CI are a real risk. Good catch.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

The architectural intent of this PR is correct and aligns with the project's layering conventions. All server-side data fetching now flows through the single createApiClient abstraction, which is the right boundary. The CONTRIBUTING.md clarification about event.fetch vs global fetch for multipart uploads fills a real documentation gap.

Blockers

None.

Observations

Consistency of the data-access boundary is now enforced in the admin module — good
Previously, admin/invites was the last holdout using raw fetch with manual URL construction and env.API_INTERNAL_URL. Having this consolidated means any change to the API base URL, headers, or auth injection in handleFetch is automatically picked up everywhere — no more separate branches of the fetch logic to maintain.

The InviteListItem local interface is still alive — consider whether it should outlive this PR
The interface exists because the generated type marks shareableUrl as optional while the backend always populates it. Now that InviteListItemDTO.java has the correct @Schema(requiredMode = REQUIRED) annotation, this local re-declaration should be temporary. After npm run generate:api is run and the generated type is updated, the local interface and all the as unknown as InviteListItem[] casts should collapse. If that regeneration is intentionally deferred, it would be useful to note this in the PR description (it's not currently mentioned).

The VALID_STATUSES guard pattern is a good precedent
Validating an enum-like query param server-side before forwarding it to the typed client avoids a class of runtime errors where the generated type's union doesn't match what the backend actually validates. This pattern is worth extracting to a shared utility (parseEnumParam<T>) if it appears in more load functions.

No cross-domain boundary violations introduced
The changes stay strictly within the frontend module — no backend service calls repositories they shouldn't, no new cross-domain coupling introduced. The backend change to InviteListItemDTO.java is a single-line annotation correction, not a structural change.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** The architectural intent of this PR is correct and aligns with the project's layering conventions. All server-side data fetching now flows through the single `createApiClient` abstraction, which is the right boundary. The CONTRIBUTING.md clarification about `event.fetch` vs global `fetch` for multipart uploads fills a real documentation gap. ### Blockers None. ### Observations **Consistency of the data-access boundary is now enforced in the admin module — good** Previously, `admin/invites` was the last holdout using raw `fetch` with manual URL construction and `env.API_INTERNAL_URL`. Having this consolidated means any change to the API base URL, headers, or auth injection in `handleFetch` is automatically picked up everywhere — no more separate branches of the fetch logic to maintain. **The `InviteListItem` local interface is still alive — consider whether it should outlive this PR** The interface exists because the generated type marks `shareableUrl` as optional while the backend always populates it. Now that `InviteListItemDTO.java` has the correct `@Schema(requiredMode = REQUIRED)` annotation, this local re-declaration should be temporary. After `npm run generate:api` is run and the generated type is updated, the local interface and all the `as unknown as InviteListItem[]` casts should collapse. If that regeneration is intentionally deferred, it would be useful to note this in the PR description (it's not currently mentioned). **The `VALID_STATUSES` guard pattern is a good precedent** Validating an enum-like query param server-side before forwarding it to the typed client avoids a class of runtime errors where the generated type's union doesn't match what the backend actually validates. This pattern is worth extracting to a shared utility (`parseEnumParam<T>`) if it appears in more load functions. **No cross-domain boundary violations introduced** The changes stay strictly within the frontend module — no backend service calls repositories they shouldn't, no new cross-domain coupling introduced. The backend change to `InviteListItemDTO.java` is a single-line annotation correction, not a structural change.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This PR removes a category of security risk (raw fetch calls that could accidentally use global fetch in server actions instead of event.fetch) and centralises all server-side HTTP calls through the createApiClient abstraction. From a security standpoint, this is the right direction.

Blockers

None.

Observations

event.fetch enforcement is the core security fix here
The PR description correctly identifies that event.fetch is required so handleFetch can inject the session cookie on all outgoing backend calls. Using global fetch or env.API_INTERNAL_URL + raw fetch in a server action bypasses that hook entirely, meaning the request arrives at the backend unauthenticated. The consolidation to createApiClient(fetch) (where fetch is the event's fetch) closes that gap correctly in both the load and action paths.

No new input validation introduced, but also none removed
The VALID_STATUSES whitelist in invites is new and welcome — it prevents an attacker from injecting an arbitrary query parameter value into the backend call. However, the id parameter in the revoke action (formData.get('id') as string) is passed directly into the path template { params: { path: { id } } } without validation. openapi-fetch will URL-encode the value in the path, so injection into the HTTP path itself is unlikely, but an empty or malformed id will produce a 404 or 400 from the backend rather than a client-side guard. This is consistent with the rest of the codebase and not a blocker, but worth noting.

No secrets exposed
The removal of the env import from admin/invites/+page.server.ts is net positive — API_INTERNAL_URL is no longer read directly in the actions, reducing the number of places where that env var is accessed. The value is still centralised in api.server.ts.

CONTRIBUTING.md clarification is a genuine security improvement
Documenting that multipart uploads must use event.fetch (never global fetch) converts an implicit, easy-to-miss convention into an explicit, searchable rule. Future contributors will not accidentally regress this.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This PR removes a category of security risk (raw `fetch` calls that could accidentally use global fetch in server actions instead of `event.fetch`) and centralises all server-side HTTP calls through the `createApiClient` abstraction. From a security standpoint, this is the right direction. ### Blockers None. ### Observations **`event.fetch` enforcement is the core security fix here** The PR description correctly identifies that `event.fetch` is required so `handleFetch` can inject the session cookie on all outgoing backend calls. Using global `fetch` or `env.API_INTERNAL_URL` + raw fetch in a server action bypasses that hook entirely, meaning the request arrives at the backend unauthenticated. The consolidation to `createApiClient(fetch)` (where `fetch` is the event's fetch) closes that gap correctly in both the load and action paths. **No new input validation introduced, but also none removed** The `VALID_STATUSES` whitelist in invites is new and welcome — it prevents an attacker from injecting an arbitrary query parameter value into the backend call. However, the `id` parameter in the `revoke` action (`formData.get('id') as string`) is passed directly into the path template `{ params: { path: { id } } }` without validation. `openapi-fetch` will URL-encode the value in the path, so injection into the HTTP path itself is unlikely, but an empty or malformed `id` will produce a 404 or 400 from the backend rather than a client-side guard. This is consistent with the rest of the codebase and not a blocker, but worth noting. **No secrets exposed** The removal of the `env` import from `admin/invites/+page.server.ts` is net positive — `API_INTERNAL_URL` is no longer read directly in the actions, reducing the number of places where that env var is accessed. The value is still centralised in `api.server.ts`. **CONTRIBUTING.md clarification is a genuine security improvement** Documenting that multipart uploads must use `event.fetch` (never global `fetch`) converts an implicit, easy-to-miss convention into an explicit, searchable rule. Future contributors will not accidentally regress this.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

The test additions are thorough and the fix for 86 pre-existing test failures is excellent work. The root cause (Sentry wrapping requiring request and url on event mocks) is correctly identified and uniformly applied across 14 spec files. The new page.server.spec.ts for the users update action follows the AAA pattern and has good coverage of the error branches. A few concerns worth addressing.

Blockers

None — no new test gaps introduced. The concerns below are about completeness.

Suggestions

The create action — no test for the expiresAt field
The new tests for create check groupIds (both populated and empty) and the success/error/fallback paths. There is no test verifying that expiresAt is forwarded correctly to the backend when provided, nor that it is absent from the request body when not set. Since expiresAt is an optional date field, the serialisation edge cases (empty string vs undefined vs omitted) are worth a dedicated test case.

The revoke action — no test for missing/empty id
formData.get('id') as string will be null if the field is absent. The as string cast hides this — null will be passed as "null" in the URL path, which produces an unintended backend call. A test asserting the guard behaviour (or lack thereof) would document the current contract explicitly.

The delete action in users/[id]/page.server.spec.ts event mock is missing request
The makeEvent() for the delete action tests does not include request in the event shape. This is currently fine because the delete action doesn't read from request.formData(), but it's inconsistent with the other event factories in the file and would silently break if the action is later modified to read form data.

mockResponse helper — null body handling
In the invites spec, mockResponse(false, null, 500) is used to simulate a response with no JSON body. It's not immediately clear how openapi-fetch handles a null body when it tries to parse the error — a comment explaining why this correctly exercises the fallback path would help future maintainers.

Positive: the as any eslint-disable pattern is consistently scoped
Each as any cast has an inline // eslint-disable-next-line comment directly above the problematic line, not a block disable. This is the right scope and makes it easy to clean up later when SvelteKit tightens the event types.

The CI fix (; → &&) should have a regression test
The semicolon bug meant the coverage run for the client side always ran regardless of server test results. There's no way to prevent this class of bug from recurring without a CI-level check (e.g. a test that verifies the test:coverage script exits non-zero when server tests fail). Not blocking, but worth a follow-up issue.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** The test additions are thorough and the fix for 86 pre-existing test failures is excellent work. The root cause (Sentry wrapping requiring `request` and `url` on event mocks) is correctly identified and uniformly applied across 14 spec files. The new `page.server.spec.ts` for the users update action follows the AAA pattern and has good coverage of the error branches. A few concerns worth addressing. ### Blockers None — no new test gaps introduced. The concerns below are about completeness. ### Suggestions **The `create` action — no test for the `expiresAt` field** The new tests for `create` check `groupIds` (both populated and empty) and the success/error/fallback paths. There is no test verifying that `expiresAt` is forwarded correctly to the backend when provided, nor that it is absent from the request body when not set. Since `expiresAt` is an optional date field, the serialisation edge cases (empty string vs `undefined` vs omitted) are worth a dedicated test case. **The revoke action — no test for missing/empty `id`** `formData.get('id') as string` will be `null` if the field is absent. The `as string` cast hides this — `null` will be passed as `"null"` in the URL path, which produces an unintended backend call. A test asserting the guard behaviour (or lack thereof) would document the current contract explicitly. **The `delete` action in `users/[id]/page.server.spec.ts` event mock is missing `request`** The `makeEvent()` for the delete action tests does not include `request` in the event shape. This is currently fine because the delete action doesn't read from `request.formData()`, but it's inconsistent with the other event factories in the file and would silently break if the action is later modified to read form data. **`mockResponse` helper — `null` body handling** In the invites spec, `mockResponse(false, null, 500)` is used to simulate a response with no JSON body. It's not immediately clear how `openapi-fetch` handles a `null` body when it tries to parse the error — a comment explaining why this correctly exercises the fallback path would help future maintainers. **Positive: the `as any` eslint-disable pattern is consistently scoped** Each `as any` cast has an inline `// eslint-disable-next-line` comment directly above the problematic line, not a block disable. This is the right scope and makes it easy to clean up later when SvelteKit tightens the event types. **The CI fix (`; → &&`) should have a regression test** The semicolon bug meant the coverage run for the client side always ran regardless of server test results. There's no way to prevent this class of bug from recurring without a CI-level check (e.g. a test that verifies the `test:coverage` script exits non-zero when server tests fail). Not blocking, but worth a follow-up issue.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Verdict: Approved

This PR is purely a server-side refactor and a test infrastructure fix. No Svelte components, templates, CSS, or user-facing strings were modified. I reviewed the diff for any accidental impact on rendered output and found none.

What I Checked

  • No .svelte files changed — all changes are in +page.server.ts, *.server.spec.ts, CONTRIBUTING.md, and InviteListItemDTO.java. Zero impact on rendered HTML, component structure, or styling.
  • No i18n keys added or removed — the error codes handled (FORBIDDEN, NOT_FOUND, INTERNAL_ERROR) already exist in the message files. No new user-visible strings were introduced.
  • Error propagation to the UI is unchanged — the fail(status, { createError: code }) and fail(status, { revokeError: code }) response shapes are identical to the old implementation. Any existing UI that reads these form action return values will continue to work without modification.
  • shareableUrl is now @Schema(requiredMode = REQUIRED) — this tightens the generated TypeScript type. If any Svelte component was guarding against shareableUrl being undefined, that guard becomes unnecessary after npm run generate:api. No components were broken by this, but it is worth noting as a follow-up cleanup opportunity.

LGTM from a UI/UX perspective.

## 🎨 Leonie Voss — UI/UX Design Lead **Verdict: ✅ Approved** This PR is purely a server-side refactor and a test infrastructure fix. No Svelte components, templates, CSS, or user-facing strings were modified. I reviewed the diff for any accidental impact on rendered output and found none. ### What I Checked - **No `.svelte` files changed** — all changes are in `+page.server.ts`, `*.server.spec.ts`, `CONTRIBUTING.md`, and `InviteListItemDTO.java`. Zero impact on rendered HTML, component structure, or styling. - **No i18n keys added or removed** — the error codes handled (`FORBIDDEN`, `NOT_FOUND`, `INTERNAL_ERROR`) already exist in the message files. No new user-visible strings were introduced. - **Error propagation to the UI is unchanged** — the `fail(status, { createError: code })` and `fail(status, { revokeError: code })` response shapes are identical to the old implementation. Any existing UI that reads these form action return values will continue to work without modification. - **`shareableUrl` is now `@Schema(requiredMode = REQUIRED)`** — this tightens the generated TypeScript type. If any Svelte component was guarding against `shareableUrl` being `undefined`, that guard becomes unnecessary after `npm run generate:api`. No components were broken by this, but it is worth noting as a follow-up cleanup opportunity. LGTM from a UI/UX perspective.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No changes to infrastructure, Docker Compose, CI pipelines, or deployment configuration. I reviewed the diff for any operational impact.

What I Checked

  • No docker-compose*.yml changes — the stack topology is unchanged.
  • No CI workflow changes — but the test:coverage fix in package.json (; → &&) has operational significance: it means CI will now correctly report a failed build when server-side tests fail, rather than silently proceeding to the client coverage run. This is the correct behaviour and I would have flagged the original as a defect.
  • API_INTERNAL_URL env var is used less directly — the env import is removed from admin/invites/+page.server.ts entirely. The env var is still read in api.server.ts which is the centralised place for it. This is a net improvement — fewer files that need this secret injected in test environments.
  • No new dependencies introducedpackage.json changes are limited to the test:coverage script. openapi-fetch was already a dependency.

The test:coverage semicolon fix is the most operationally relevant change in this PR. Silently passing CI despite 86 failing tests is a reliability risk that was being masked. Good catch.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No changes to infrastructure, Docker Compose, CI pipelines, or deployment configuration. I reviewed the diff for any operational impact. ### What I Checked - **No `docker-compose*.yml` changes** — the stack topology is unchanged. - **No CI workflow changes** — but the `test:coverage` fix in `package.json` (`; → &&`) has operational significance: it means CI will now correctly report a failed build when server-side tests fail, rather than silently proceeding to the client coverage run. This is the correct behaviour and I would have flagged the original as a defect. - **`API_INTERNAL_URL` env var is used less directly** — the `env` import is removed from `admin/invites/+page.server.ts` entirely. The env var is still read in `api.server.ts` which is the centralised place for it. This is a net improvement — fewer files that need this secret injected in test environments. - **No new dependencies introduced** — `package.json` changes are limited to the `test:coverage` script. `openapi-fetch` was already a dependency. The `test:coverage` semicolon fix is the most operationally relevant change in this PR. Silently passing CI despite 86 failing tests is a reliability risk that was being masked. Good catch.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing against the stated goal of issue #465: migrate remaining admin server actions to createApiClient, eliminate direct fetch + env.API_INTERNAL_URL usage, and fix the test infrastructure that was masking failures. All stated goals are met.

Requirements Traceability

Goal: Replace all manual fetch(${apiUrl}/api/...) calls in admin/invites/+page.server.ts
Delivered. The load, create, and revoke actions all use createApiClient(fetch). The env import is removed entirely.

Goal: Replace inline fetch('/api/users/...') in admin/users/[id]/+page.server.ts update action
Delivered. The try/catch JSON parsing is replaced with api.PUT(...) and the project-standard error extraction pattern.

Goal: Add missing tests for create/revoke invites and users update action
Delivered. 5 new test cases for the invites actions and a full page.server.spec.ts for the users page (covering load, update, and delete).

Goal: Fix 86 pre-existing test failures caused by Sentry wrapping
Delivered. 14 spec files updated to include request and url in event mocks. The PR test plan reports 576/576 passing.

Goal: Fix test:coverage semicolon
Delivered. The ; → && change in package.json ensures server test failures abort the coverage run.

Goal: Clarify event.fetch requirement in CONTRIBUTING.md
Delivered. The documentation now explicitly states event.fetch must be used (never global fetch) for multipart uploads.

Open Items / Observations

  • The PR description notes "Spring Sessions note: the auth bypass described in the issue was already resolved by #523/#524". This is consistent with the diff — event.fetch was already used in these files for session cookie injection, and this PR completes the cleanup. No requirement gap.
  • The VALID_STATUSES whitelist is a small scope expansion beyond the issue but is clearly beneficial. No concern.
  • The rename from page.server.test.tspage.server.spec.ts is a naming convention normalisation. Minor scope but desirable.
## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing against the stated goal of issue #465: migrate remaining admin server actions to `createApiClient`, eliminate direct `fetch` + `env.API_INTERNAL_URL` usage, and fix the test infrastructure that was masking failures. All stated goals are met. ### Requirements Traceability **Goal: Replace all manual `fetch(${apiUrl}/api/...)` calls in `admin/invites/+page.server.ts`** Delivered. The `load`, `create`, and `revoke` actions all use `createApiClient(fetch)`. The `env` import is removed entirely. ✅ **Goal: Replace inline `fetch('/api/users/...')` in `admin/users/[id]/+page.server.ts` `update` action** Delivered. The try/catch JSON parsing is replaced with `api.PUT(...)` and the project-standard error extraction pattern. ✅ **Goal: Add missing tests for create/revoke invites and users update action** Delivered. 5 new test cases for the invites actions and a full `page.server.spec.ts` for the users page (covering load, update, and delete). ✅ **Goal: Fix 86 pre-existing test failures caused by Sentry wrapping** Delivered. 14 spec files updated to include `request` and `url` in event mocks. The PR test plan reports 576/576 passing. ✅ **Goal: Fix `test:coverage` semicolon** Delivered. The `; → &&` change in `package.json` ensures server test failures abort the coverage run. ✅ **Goal: Clarify `event.fetch` requirement in CONTRIBUTING.md** Delivered. The documentation now explicitly states `event.fetch` must be used (never global `fetch`) for multipart uploads. ✅ ### Open Items / Observations - The PR description notes "Spring Sessions note: the auth bypass described in the issue was already resolved by #523/#524". This is consistent with the diff — `event.fetch` was already used in these files for session cookie injection, and this PR completes the cleanup. No requirement gap. - The `VALID_STATUSES` whitelist is a small scope expansion beyond the issue but is clearly beneficial. No concern. - The rename from `page.server.test.ts` → `page.server.spec.ts` is a naming convention normalisation. Minor scope but desirable.
marcel added 1 commit 2026-05-19 10:51:19 +02:00
fix(admin): address PR #623 second-pass review feedback
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
49ac1984dd
- Fix VALID_STATUSES fallback to use uppercase enum value
- Add TODO comment on InviteListItem cast pending type regeneration
- Guard revoke action against null id (returns fail 400)
- Add request: to delete action mock events for Sentry consistency
- Add expiresAt forwarding test for create action
- Add null-id guard test for revoke action

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

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

This is exactly the kind of housekeeping PR I love to see. The intent is clear, the scope is contained, and it fixes a hidden CI defect along the way. A few things to note:

Blockers

None.

Suggestions

1. Two lingering // TODO: remove cast after next npm run generate:api comments (admin/invites/+page.server.ts, both the load return and the create action return)

These TODO comments explain a transient state — the cast should go away once generate:api is run and re-committed. The PR description says this was intentional, but the TODO comment pattern leaves the door open for it to rot. The cleaner approach would be: run npm run generate:api in this same PR (since @Schema(requiredMode=REQUIRED) was added to InviteListItemDTO.shareableUrl in backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java) and eliminate the cast immediately rather than deferring it.

2. // eslint-disable-next-line @typescript-eslint/no-explicit-any appears 10+ times across the new spec files

The as any casts on partial event objects in test helpers are understandable (SvelteKit's event types are wide), but I'd prefer extracting a typed helper once and sharing it:

function makeLoadEvent(overrides: Record<string, unknown> = {}) {
    return {
        fetch: vi.fn() as unknown as typeof fetch,
        request: new Request('http://localhost/'),
        url: new URL('http://localhost/'),
        ...overrides
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } as any;
}

That keeps the suppression in one place instead of scattering it across test files.

3. page.server.spec.ts naming (new) vs page.server.test.ts (deleted)

The deleted test file was page.server.test.ts; the replacement is page.server.spec.ts. The rest of the codebase uses .spec.ts consistently — good that you aligned it, but the PR description doesn't mention this rename explicitly, so it could confuse a bisect.

4. admin/invites load tests mock fetch directly rather than createApiClient

The other spec files (e.g. admin/users/[id]/page.server.spec.ts) mock createApiClient at the module boundary, which is the cleaner pattern. The invites load tests mock the raw fetch and rely on the internal implementation of createApiClient calling fetch(Request, {}). If the internal shape of createApiClient ever changes (e.g. using a different adapter), the invites tests would break while the users tests would not. The URL assertion expect.stringContaining('/api/invites') is also slightly brittle compared to asserting on the typed path string via the mock.

Overall: clean, consistent, well-tested refactor. The fixes to the test:coverage semicolon and the Sentry mock event shape are valuable bonus improvements.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** This is exactly the kind of housekeeping PR I love to see. The intent is clear, the scope is contained, and it fixes a hidden CI defect along the way. A few things to note: ### Blockers None. ### Suggestions **1. Two lingering `// TODO: remove cast after next npm run generate:api` comments** (`admin/invites/+page.server.ts`, both the `load` return and the `create` action return) These TODO comments explain a *transient* state — the cast should go away once `generate:api` is run and re-committed. The PR description says this was intentional, but the TODO comment pattern leaves the door open for it to rot. The cleaner approach would be: run `npm run generate:api` in this same PR (since `@Schema(requiredMode=REQUIRED)` was added to `InviteListItemDTO.shareableUrl` in `backend/src/main/java/org/raddatz/familienarchiv/user/InviteListItemDTO.java`) and eliminate the cast immediately rather than deferring it. **2. `// eslint-disable-next-line @typescript-eslint/no-explicit-any` appears 10+ times across the new spec files** The `as any` casts on partial event objects in test helpers are understandable (SvelteKit's event types are wide), but I'd prefer extracting a typed helper once and sharing it: ```typescript function makeLoadEvent(overrides: Record<string, unknown> = {}) { return { fetch: vi.fn() as unknown as typeof fetch, request: new Request('http://localhost/'), url: new URL('http://localhost/'), ...overrides // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } ``` That keeps the suppression in one place instead of scattering it across test files. **3. `page.server.spec.ts` naming (new) vs `page.server.test.ts` (deleted)** The deleted test file was `page.server.test.ts`; the replacement is `page.server.spec.ts`. The rest of the codebase uses `.spec.ts` consistently — good that you aligned it, but the PR description doesn't mention this rename explicitly, so it could confuse a bisect. **4. `admin/invites` load tests mock `fetch` directly rather than `createApiClient`** The other spec files (e.g. `admin/users/[id]/page.server.spec.ts`) mock `createApiClient` at the module boundary, which is the cleaner pattern. The invites load tests mock the raw `fetch` and rely on the internal implementation of `createApiClient` calling `fetch(Request, {})`. If the internal shape of `createApiClient` ever changes (e.g. using a different adapter), the invites tests would break while the users tests would not. The URL assertion `expect.stringContaining('/api/invites')` is also slightly brittle compared to asserting on the typed path string via the mock. Overall: clean, consistent, well-tested refactor. The fixes to the `test:coverage` semicolon and the Sentry mock event shape are valuable bonus improvements.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

The PR is a pure refactor within existing module boundaries — no new layers, no new abstractions, no new infrastructure. Good scope discipline.

Blockers

None.

Suggestions

1. No documentation updates required (verified)

Checking against the doc-trigger table:

  • No new Flyway migrations → no DB diagram update needed
  • No new backend packages or routes → no CLAUDE.md/C4 diagram update needed
  • No new Docker services → no DEPLOYMENT.md update needed
  • Auth flow change? The PR explicitly states the auth bypass was already fixed in #523/#524 and this PR only changes which HTTP client wrapper is used on the server side. The session cookie forwarding path is unchanged. No seq-auth-flow.puml update required.
  • The CONTRIBUTING.md update to the multipart/fetch guidance is the right artifact to touch and was correctly updated.

All doc triggers clean.

2. The VALID_STATUSES whitelist in admin/invites/+page.server.ts is a good pattern

const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const;
const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus)
    ? (rawStatus as InviteStatus)
    : 'ACTIVE';

This is the correct place for input sanitization — the server boundary. Worth calling out as a positive: this was silently accepting arbitrary user input as a URL query parameter in the old code and interpolating it directly into the API URL via encodeURIComponent. The typed client approach forces this validation to be explicit.

3. The as unknown as InviteListItem double cast is a smell, not a blocker

Two // TODO: remove cast comments signal that the TypeScript types are temporarily out of sync with the backend. This is acceptable for a short window, but the TODO should have a linked issue or be resolved in this same PR by running generate:api. The @Schema(requiredMode=REQUIRED) annotation was added to shareableUrl in this very PR — running codegen would close the loop cleanly.

4. admin/users/[id] update action: body typed as components['schemas']['AdminUpdateUserRequest']

The cast comment explains why null fields are sent ("the backend treats null as 'clear this field'"). This is an intentional design decision but it means the TypeScript schema type and the actual runtime shape diverge. This is a schema design issue (the OpenAPI spec should model nullable fields as nullable: true) rather than a code problem, but worth tracking.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** The PR is a pure refactor within existing module boundaries — no new layers, no new abstractions, no new infrastructure. Good scope discipline. ### Blockers None. ### Suggestions **1. No documentation updates required (verified)** Checking against the doc-trigger table: - No new Flyway migrations → no DB diagram update needed - No new backend packages or routes → no CLAUDE.md/C4 diagram update needed - No new Docker services → no DEPLOYMENT.md update needed - Auth flow change? The PR explicitly states the auth bypass was already fixed in #523/#524 and this PR only changes which HTTP client wrapper is used on the server side. The session cookie forwarding path is unchanged. No `seq-auth-flow.puml` update required. - The `CONTRIBUTING.md` update to the multipart/fetch guidance is the right artifact to touch and was correctly updated. All doc triggers clean. **2. The `VALID_STATUSES` whitelist in `admin/invites/+page.server.ts` is a good pattern** ```typescript const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const; const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus) ? (rawStatus as InviteStatus) : 'ACTIVE'; ``` This is the correct place for input sanitization — the server boundary. Worth calling out as a positive: this was silently accepting arbitrary user input as a URL query parameter in the old code and interpolating it directly into the API URL via `encodeURIComponent`. The typed client approach forces this validation to be explicit. **3. The `as unknown as InviteListItem` double cast is a smell, not a blocker** Two `// TODO: remove cast` comments signal that the TypeScript types are temporarily out of sync with the backend. This is acceptable for a short window, but the TODO should have a linked issue or be resolved in this same PR by running `generate:api`. The `@Schema(requiredMode=REQUIRED)` annotation was added to `shareableUrl` in this very PR — running codegen would close the loop cleanly. **4. `admin/users/[id]` `update` action: body typed as `components['schemas']['AdminUpdateUserRequest']`** The cast comment explains why null fields are sent (`"the backend treats null as 'clear this field'"`). This is an intentional design decision but it means the TypeScript schema type and the actual runtime shape diverge. This is a schema design issue (the OpenAPI spec should model nullable fields as `nullable: true`) rather than a code problem, but worth tracking.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

This PR is a net security improvement. Replacing raw fetch(${apiUrl}/api/...) with the typed createApiClient(fetch) pattern eliminates three distinct security concerns at once.

Positive Findings

1. Input validation added to revoke action (new guard)

const id = formData.get('id') as string | null;
if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') });

The old code did const id = formData.get('id') as string and passed it directly into the URL: fetch(\${apiUrl}/api/invites/${encodeURIComponent(id)}`). This meant a missing idwould result in a request to/api/invites/null(the string "null") or/api/invites/` rather than a clean 400. The null guard here is correct.

2. URL injection risk eliminated

The old pattern fetch(\${apiUrl}/api/invites?status=${encodeURIComponent(status)}`)relied on correctencodeURIComponent` usage. The new typed client constructs URLs from typed path/query parameters — no interpolation, no injection surface.

3. VALID_STATUSES whitelist for query parameter

The old code passed the status query param directly into a URL after encoding. The new code validates it against a hardcoded enum before passing it to the typed client. This is defense in depth — even if the typed client later accepted arbitrary strings, the whitelist would block abuse.

Concerns (non-blockers)

4. Error code pass-through in create and revoke actions

return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });

code here comes from the raw backend error body. If the backend returns an error code that has no corresponding i18n mapping on the frontend, getErrorMessage() falls back to a generic message — good. But the raw backend error code string is being stored in SvelteKit's form data which gets serialized and sent back to the client. This is not a leak of sensitive internals (it's an enum value like 'FORBIDDEN'), but verify that no ErrorCode in the backend enum contains sensitive information (SQL state, table names, etc.). Based on my knowledge of this codebase's ErrorCode enum, this is clean.

5. as unknown as InviteListItem cast

invites = (invitesResult.data ?? []) as unknown as InviteListItem[];

The double cast bypasses TypeScript's type safety. If the backend sends a response that doesn't match InviteListItem (e.g. shareableUrl is actually null), downstream code using invite.shareableUrl as non-null could fail at runtime. This is a temporary state awaiting generate:api, but during this window there is a narrow risk of a null-pointer-style failure in the invite list rendering. Not a security issue, but worth resolving promptly.

6. Session cookie forwarding (confirmed correct)

The PR description notes that event.fetch was already being used everywhere — this PR is completing the typed client migration, not changing the fetch implementation. The handleFetch hook ensures session cookies are forwarded on internal requests. The CONTRIBUTING.md update correctly documents that multipart uploads must use event.fetch directly (never global fetch) for this reason. This is the right constraint to document.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** This PR is a net security improvement. Replacing raw `fetch(${apiUrl}/api/...)` with the typed `createApiClient(fetch)` pattern eliminates three distinct security concerns at once. ### Positive Findings **1. Input validation added to `revoke` action (new guard)** ```typescript const id = formData.get('id') as string | null; if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') }); ``` The old code did `const id = formData.get('id') as string` and passed it directly into the URL: `fetch(\`${apiUrl}/api/invites/${encodeURIComponent(id)}\`)`. This meant a missing `id` would result in a request to `/api/invites/null` (the string "null") or `/api/invites/` rather than a clean 400. The null guard here is correct. **2. URL injection risk eliminated** The old pattern `fetch(\`${apiUrl}/api/invites?status=${encodeURIComponent(status)}\`)` relied on correct `encodeURIComponent` usage. The new typed client constructs URLs from typed path/query parameters — no interpolation, no injection surface. **3. `VALID_STATUSES` whitelist for query parameter** The old code passed the `status` query param directly into a URL after encoding. The new code validates it against a hardcoded enum before passing it to the typed client. This is defense in depth — even if the typed client later accepted arbitrary strings, the whitelist would block abuse. ### Concerns (non-blockers) **4. Error code pass-through in `create` and `revoke` actions** ```typescript return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' }); ``` `code` here comes from the raw backend error body. If the backend returns an error code that has no corresponding i18n mapping on the frontend, `getErrorMessage()` falls back to a generic message — good. But the raw backend error `code` string is being stored in SvelteKit's form data which gets serialized and sent back to the client. This is not a leak of sensitive internals (it's an enum value like `'FORBIDDEN'`), but verify that no `ErrorCode` in the backend enum contains sensitive information (SQL state, table names, etc.). Based on my knowledge of this codebase's `ErrorCode` enum, this is clean. **5. `as unknown as InviteListItem` cast** ```typescript invites = (invitesResult.data ?? []) as unknown as InviteListItem[]; ``` The double cast bypasses TypeScript's type safety. If the backend sends a response that doesn't match `InviteListItem` (e.g. `shareableUrl` is actually null), downstream code using `invite.shareableUrl` as non-null could fail at runtime. This is a temporary state awaiting `generate:api`, but during this window there is a narrow risk of a null-pointer-style failure in the invite list rendering. Not a security issue, but worth resolving promptly. **6. Session cookie forwarding (confirmed correct)** The PR description notes that `event.fetch` was already being used everywhere — this PR is completing the typed client migration, not changing the fetch implementation. The `handleFetch` hook ensures session cookies are forwarded on internal requests. The `CONTRIBUTING.md` update correctly documents that multipart uploads must use `event.fetch` directly (never global `fetch`) for this reason. This is the right constraint to document.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approved

The test story in this PR is the main event. 86 previously-hidden test failures surfaced and fixed, a broken test:coverage command repaired, and new coverage added for previously-untested actions. That's a genuine quality uplift.

Positive Findings

1. The semicolon → && fix in test:coverage is a blocker-fix in disguise

-"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
+"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",

With ; a failing server run silently allowed the client run to succeed and report green. CI was lying. This single character change restores the meaning of the coverage gate. High impact, low risk.

2. Root cause of 86 failures correctly identified and fixed across 14 files

The @sentry/sveltekit wrapper reads event.request.method and event.url.pathname at instrumentation time. Test events that only provided { fetch, url } or { fetch, locals } caused Sentry's wrapper to throw, silently swallowing the real test failure. Adding request: new Request(...) and url: new URL(...) to every test event is the correct minimal fix — it satisfies the wrapper without changing the test's intent.

3. New page.server.spec.ts for admin/invites (284 lines)

Good coverage across all three actions (load, create, revoke):

  • Happy paths
  • Error paths with backend error code
  • Error paths with no code (INTERNAL_ERROR fallback)
  • Input guard (missing revoke id)
  • Parallel fetch assertion
  • groupIds array vs empty array
  • expiresAt forwarding

4. New admin/users/[id]/page.server.spec.ts (199 lines)

Covers load (permission guard, 404, success), update (success, backend error, no-code fallback, password mismatch guard), and delete (redirect, failure). This is the first test coverage for the users admin update action.

Concerns

5. Invites load tests mock raw fetch while users tests mock createApiClient

Two different mocking strategies in the same PR is a consistency gap. The invites load tests assert URL strings via expect.stringContaining('/api/invites'), while the users tests assert the typed path string '/api/users/{id}'. The typed-path assertion is strictly better — it catches typos in the path template. Consider aligning the invites load tests to mock at the module boundary:

vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
// ...
vi.mocked(createApiClient).mockReturnValue({ GET: mockGetFn } as ReturnType<typeof createApiClient>);

This is a suggestion, not a blocker — both approaches work.

6. No test for the status validation in the invites load function

The new VALID_STATUSES whitelist defaults invalid values to 'ACTIVE'. There is no test asserting this behavior:

it('defaults to ACTIVE status when an invalid status query param is passed', async () => {
    // ...
    const result = await load(event('bogus'));
    expect(result.status).toBe('ACTIVE');
});

Suggestion: add this test. The validation is a security-relevant input guard and deserves explicit coverage.

7. test:coverage passes with 576/576 — verify this is stable

The PR description reports 576/576. If any of the 86 previously-failing tests were also flaky, they could become intermittent failures. Worth a second CI run to confirm stability before merge.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ✅ Approved** The test story in this PR is the main event. 86 previously-hidden test failures surfaced and fixed, a broken `test:coverage` command repaired, and new coverage added for previously-untested actions. That's a genuine quality uplift. ### Positive Findings **1. The semicolon → `&&` fix in `test:coverage` is a blocker-fix in disguise** ```diff -"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage", +"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage", ``` With `;` a failing server run silently allowed the client run to succeed and report green. CI was lying. This single character change restores the meaning of the coverage gate. High impact, low risk. **2. Root cause of 86 failures correctly identified and fixed across 14 files** The `@sentry/sveltekit` wrapper reads `event.request.method` and `event.url.pathname` at instrumentation time. Test events that only provided `{ fetch, url }` or `{ fetch, locals }` caused Sentry's wrapper to throw, silently swallowing the real test failure. Adding `request: new Request(...)` and `url: new URL(...)` to every test event is the correct minimal fix — it satisfies the wrapper without changing the test's intent. **3. New `page.server.spec.ts` for `admin/invites` (284 lines)** Good coverage across all three actions (load, create, revoke): - Happy paths ✅ - Error paths with backend error code ✅ - Error paths with no code (INTERNAL_ERROR fallback) ✅ - Input guard (missing revoke id) ✅ - Parallel fetch assertion ✅ - groupIds array vs empty array ✅ - expiresAt forwarding ✅ **4. New `admin/users/[id]/page.server.spec.ts` (199 lines)** Covers load (permission guard, 404, success), update (success, backend error, no-code fallback, password mismatch guard), and delete (redirect, failure). This is the first test coverage for the users admin update action. ### Concerns **5. Invites load tests mock raw `fetch` while users tests mock `createApiClient`** Two different mocking strategies in the same PR is a consistency gap. The invites load tests assert URL strings via `expect.stringContaining('/api/invites')`, while the users tests assert the typed path string `'/api/users/{id}'`. The typed-path assertion is strictly better — it catches typos in the path template. Consider aligning the invites load tests to mock at the module boundary: ```typescript vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() })); // ... vi.mocked(createApiClient).mockReturnValue({ GET: mockGetFn } as ReturnType<typeof createApiClient>); ``` This is a suggestion, not a blocker — both approaches work. **6. No test for the `status` validation in the invites `load` function** The new `VALID_STATUSES` whitelist defaults invalid values to `'ACTIVE'`. There is no test asserting this behavior: ```typescript it('defaults to ACTIVE status when an invalid status query param is passed', async () => { // ... const result = await load(event('bogus')); expect(result.status).toBe('ACTIVE'); }); ``` Suggestion: add this test. The validation is a security-relevant input guard and deserves explicit coverage. **7. `test:coverage` passes with 576/576 — verify this is stable** The PR description reports 576/576. If any of the 86 previously-failing tests were also flaky, they could become intermittent failures. Worth a second CI run to confirm stability before merge.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

This PR touches no infrastructure files — no Compose changes, no CI workflow changes, no Dockerfile changes. My review is focused on the one CI-relevant change: the test:coverage script fix.

Positive Findings

1. The ; → && fix in package.json is a genuine CI correctness fix

-"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
+"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",

With the old ; separator, a non-zero exit code from the server coverage run was silently discarded. The shell executed the client run regardless and that run's exit code became the job's exit code. If the CI pipeline uses npm run test:coverage as a quality gate, this was a phantom green gate. The && ensures the coverage gate actually gates on both halves.

2. No changes to CI workflow files

The fix works because the change is in package.json — the CI script calls npm run test:coverage and picks up the fix automatically. No pipeline YAML changes needed.

Concerns

3. Verify CI actually calls test:coverage rather than test

The test script runs npm run test:unit -- --run which doesn't apply && vs ; distinction. If the CI pipeline uses npm run test (not npm run test:coverage), the fix has no effect on CI. Worth confirming which script CI actually calls — if it's test:unit or test, the coverage gate may not be active in CI at all and this fix only helps local developers running npm run test:coverage manually.

This is a follow-up observation, not a blocker for this PR.

## 🚀 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** This PR touches no infrastructure files — no Compose changes, no CI workflow changes, no Dockerfile changes. My review is focused on the one CI-relevant change: the `test:coverage` script fix. ### Positive Findings **1. The `; → &&` fix in `package.json` is a genuine CI correctness fix** ```diff -"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage", +"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage", ``` With the old `;` separator, a non-zero exit code from the server coverage run was silently discarded. The shell executed the client run regardless and that run's exit code became the job's exit code. If the CI pipeline uses `npm run test:coverage` as a quality gate, this was a phantom green gate. The `&&` ensures the coverage gate actually gates on both halves. **2. No changes to CI workflow files** The fix works because the change is in `package.json` — the CI script calls `npm run test:coverage` and picks up the fix automatically. No pipeline YAML changes needed. ### Concerns **3. Verify CI actually calls `test:coverage` rather than `test`** The `test` script runs `npm run test:unit -- --run` which doesn't apply `&&` vs `;` distinction. If the CI pipeline uses `npm run test` (not `npm run test:coverage`), the fix has no effect on CI. Worth confirming which script CI actually calls — if it's `test:unit` or `test`, the coverage gate may not be active in CI at all and this fix only helps local developers running `npm run test:coverage` manually. This is a follow-up observation, not a blocker for this PR.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

Reviewing from a requirements completeness and intent-traceability perspective.

Positive Findings

1. Issue #465 scope is well-contained and fully delivered

The PR description documents the original requirement (migrate fetch() calls to createApiClient) and is explicit that the auth bypass concern (the original motivation for issue #465) was already resolved by #523/#524. This is good requirements hygiene — it prevents the scope from expanding to re-implement something already done, and it provides a clear audit trail.

2. The CONTRIBUTING.md update is the right artifact

The clarification that multipart uploads must use event.fetch directly (never global fetch) is a developer-facing requirements artifact. It prevents future regressions by encoding the constraint at the documentation layer. This is exactly the kind of NFR (the session cookie forwarding contract) that needs to be written down, not just understood by whoever implemented it.

3. Acceptance criteria from the PR description are verifiable

  • grep -rE "fetch\(\?${.*API|fetch(env." frontend/src/routes` — no matches outside login/
  • npm run check — clean on touched files
  • npm run lint — clean
  • 576/576 tests passing

These are measurable, binary criteria.

Concerns

4. The status parameter validation introduces an implicit requirement change

Old behavior: url.searchParams.get('status') ?? 'active' — defaulted to lowercase 'active'.
New behavior: validates against ['ACTIVE', 'REVOKED', 'EXPIRED'], defaults to 'ACTIVE' (uppercase).

If any existing bookmarked URL uses ?status=active (lowercase), the new code will fall through to the 'ACTIVE' default, which has the same effect — but the behavior is subtly different. Worth confirming with the issue author that the backend accepts uppercase status values and that the old lowercase 'active' was either already uppercased by the backend or was wrong from the start.

5. The page.server.test.ts → page.server.spec.ts rename is undocumented

The deleted file is page.server.test.ts and the replacement is page.server.spec.ts. This is a naming convention alignment (the project uses .spec.ts), but it should be mentioned in the PR description to avoid confusion when reading git history. Minor.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** Reviewing from a requirements completeness and intent-traceability perspective. ### Positive Findings **1. Issue #465 scope is well-contained and fully delivered** The PR description documents the original requirement (migrate `fetch()` calls to `createApiClient`) and is explicit that the auth bypass concern (the original motivation for issue #465) was already resolved by #523/#524. This is good requirements hygiene — it prevents the scope from expanding to re-implement something already done, and it provides a clear audit trail. **2. The `CONTRIBUTING.md` update is the right artifact** The clarification that multipart uploads must use `event.fetch` directly (never global `fetch`) is a developer-facing requirements artifact. It prevents future regressions by encoding the constraint at the documentation layer. This is exactly the kind of NFR (the session cookie forwarding contract) that needs to be written down, not just understood by whoever implemented it. **3. Acceptance criteria from the PR description are verifiable** - `grep -rE "fetch\(\`?\${.*API|fetch\(env\." frontend/src/routes` — no matches outside login/ ✅ - `npm run check` — clean on touched files ✅ - `npm run lint` — clean ✅ - 576/576 tests passing ✅ These are measurable, binary criteria. ### Concerns **4. The `status` parameter validation introduces an implicit requirement change** Old behavior: `url.searchParams.get('status') ?? 'active'` — defaulted to lowercase `'active'`. New behavior: validates against `['ACTIVE', 'REVOKED', 'EXPIRED']`, defaults to `'ACTIVE'` (uppercase). If any existing bookmarked URL uses `?status=active` (lowercase), the new code will fall through to the `'ACTIVE'` default, which has the same effect — but the behavior is subtly different. Worth confirming with the issue author that the backend accepts uppercase status values and that the old lowercase `'active'` was either already uppercased by the backend or was wrong from the start. **5. The `page.server.test.ts → page.server.spec.ts` rename is undocumented** The deleted file is `page.server.test.ts` and the replacement is `page.server.spec.ts`. This is a naming convention alignment (the project uses `.spec.ts`), but it should be mentioned in the PR description to avoid confusion when reading git history. Minor.
Author
Owner

🎨 Leonie Voss — UI/UX Design & Accessibility Lead

Verdict: Approved

This PR is entirely backend (server-side SvelteKit +page.server.ts files) and test infrastructure. There are no Svelte component changes, no template markup changes, no CSS changes, and no i18n changes. Nothing in this PR affects what any user sees, the touch targets they interact with, the color contrast they rely on, or the accessibility tree that screen readers traverse.

The one UI-adjacent change is in CONTRIBUTING.md — a developer documentation update. It correctly clarifies that event.fetch must be used for multipart uploads to preserve session cookie forwarding. This does not affect the rendered UI.

One observation worth noting for future PRs:

The revoke action now returns getErrorMessage('VALIDATION_ERROR') as the revokeError string when the id is missing. Verify that VALIDATION_ERROR has an i18n key in all three locales (de, en, es) — the UI displays revokeError directly. If the key is missing, users would see a raw error code string instead of a localized message. This is not introduced by this PR (the pattern is established project-wide), but it's worth a quick grep check as a follow-up.

## 🎨 Leonie Voss — UI/UX Design & Accessibility Lead **Verdict: ✅ Approved** This PR is entirely backend (server-side SvelteKit `+page.server.ts` files) and test infrastructure. There are no Svelte component changes, no template markup changes, no CSS changes, and no i18n changes. Nothing in this PR affects what any user sees, the touch targets they interact with, the color contrast they rely on, or the accessibility tree that screen readers traverse. The one UI-adjacent change is in `CONTRIBUTING.md` — a developer documentation update. It correctly clarifies that `event.fetch` must be used for multipart uploads to preserve session cookie forwarding. This does not affect the rendered UI. **One observation worth noting for future PRs:** The `revoke` action now returns `getErrorMessage('VALIDATION_ERROR')` as the `revokeError` string when the id is missing. Verify that `VALIDATION_ERROR` has an i18n key in all three locales (`de`, `en`, `es`) — the UI displays `revokeError` directly. If the key is missing, users would see a raw error code string instead of a localized message. This is not introduced by this PR (the pattern is established project-wide), but it's worth a quick grep check as a follow-up.
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/issue-465-event-fetch-refactor:feat/issue-465-event-fetch-refactor
git checkout feat/issue-465-event-fetch-refactor
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#623