Friendly app-level validation for a RANGE with end-before-start (#678) #706

Merged
marcel merged 8 commits from feat/issue-678-range-date-validation into main 2026-06-01 13:11:57 +02:00
Owner

Closes #678.

Turns the "RANGE with an invalid end date" class of errors from an HTTP 500 + Sentry alert into a friendly, self-identifying 400 — without weakening the DB CHECK, which stays the backstop.

What changed

Backend

  • New ErrorCode.INVALID_DATE_RANGE (400).
  • DocumentService.validateDateRange(doc) — one guard, two predicates, run right after applyDatePrecision (i.e. before any saveupdateDocumentTags persists earlier in updateDocument), validating the post-apply doc state:
    1. End before start — mirrors chk_meta_date_end_after_start exactly (isBefore, so equal dates and a null start are valid).
    2. End set without RANGE — mirrors chk_meta_date_end_only_for_range (API-only defense; same code, distinct dev message).
  • GlobalExceptionHandler now has a generic @ExceptionHandler(DataIntegrityViolationException) backstop → 400 VALIDATION_ERROR with a fixed message. It never echoes ex.getMessage() (constraint name + SQL, CWE-209), logs at WARN without passing the exception (would re-leak the SQL to Loki), and does not call Sentry.captureException.

Frontend

  • WhoWhenSection.svelte: endBeforeStart derived (lexicographic ISO compare, no Date) renders an inline error on the end-date field — border-red-400, aria-invalid, aria-describedby, a #end-date-error <p> inside the existing aria-live region, with a ⚠ glyph so the cue is not colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate.
  • errors.ts maps INVALID_DATE_RANGEm.error_invalid_date_range(); single new i18n key added to de/en/es, used both inline (client) and via getErrorMessage (server fallback).

Acceptance criteria

  • AC1/AC5 — inline error appears on end-before-start and clears on correction (WhoWhenSection.svelte.test.ts).
  • AC2/AC3/AC4 — equal dates, open-ended (null end), and null-start+end accepted (DocumentServiceTest); AC2 also proven against real Postgres (DocumentRepositoryTest, Testcontainers).
  • AC6/AC8 — end-before-start and end-without-RANGE rejected with 400 INVALID_DATE_RANGE, never reaching the DB (DocumentServiceTest).
  • AC9 — DataIntegrityViolationException → 400 VALIDATION_ERROR, body free of chk_/meta_date, WARN log, no Sentry (GlobalExceptionHandlerTest).

Verification

  • ./mvnw test -Dtest=DocumentServiceTest → 174 green; -Dtest=GlobalExceptionHandlerTest → green; -Dtest=DocumentRepositoryTest#save_acceptsRange_whenEndEqualsStart → green (Testcontainers). ./mvnw clean package -DskipTests → BUILD SUCCESS.
  • Frontend npm run check clean for the touched files (Svelte MCP autofixer reported no issues); browser-mode component tests left to CI per project convention.

Follow-up (out of scope, noted from implementation)

  • Switching a stored RANGE doc to a non-RANGE precision via the edit form keeps the previously-stored end date (the empty hidden field binds to null, which applyDatePrecision skips rather than clears). Today that already 500s at the DB; this PR makes it a clean 400, but the ideal fix is to clear metaDateEnd when precision leaves RANGE so the switch simply succeeds. Worth a small follow-up issue.

🤖 Generated with Claude Code

Closes #678. Turns the "RANGE with an invalid end date" class of errors from an HTTP 500 + Sentry alert into a friendly, self-identifying 400 — without weakening the DB CHECK, which stays the backstop. ## What changed **Backend** - New `ErrorCode.INVALID_DATE_RANGE` (400). - `DocumentService.validateDateRange(doc)` — one guard, two predicates, run right after `applyDatePrecision` (i.e. **before any save** — `updateDocumentTags` persists earlier in `updateDocument`), validating the *post-apply* doc state: 1. End before start — mirrors `chk_meta_date_end_after_start` exactly (`isBefore`, so equal dates and a null start are valid). 2. End set without RANGE — mirrors `chk_meta_date_end_only_for_range` (API-only defense; same code, distinct dev message). - `GlobalExceptionHandler` now has a generic `@ExceptionHandler(DataIntegrityViolationException)` backstop → 400 `VALIDATION_ERROR` with a **fixed** message. It never echoes `ex.getMessage()` (constraint name + SQL, CWE-209), logs at WARN **without** passing the exception (would re-leak the SQL to Loki), and does **not** call `Sentry.captureException`. **Frontend** - `WhoWhenSection.svelte`: `endBeforeStart` derived (lexicographic ISO compare, no `Date`) renders an inline error on the end-date field — `border-red-400`, `aria-invalid`, `aria-describedby`, a `#end-date-error` `<p>` inside the existing `aria-live` region, with a ⚠ glyph so the cue is not colour-alone (WCAG 1.4.1). Save is **not** disabled; the server stays the gate. - `errors.ts` maps `INVALID_DATE_RANGE` → `m.error_invalid_date_range()`; single new i18n key added to de/en/es, used both inline (client) and via `getErrorMessage` (server fallback). ## Acceptance criteria - AC1/AC5 — inline error appears on end-before-start and clears on correction (`WhoWhenSection.svelte.test.ts`). - AC2/AC3/AC4 — equal dates, open-ended (null end), and null-start+end accepted (`DocumentServiceTest`); AC2 also proven against **real Postgres** (`DocumentRepositoryTest`, Testcontainers). - AC6/AC8 — end-before-start and end-without-RANGE rejected with 400 `INVALID_DATE_RANGE`, never reaching the DB (`DocumentServiceTest`). - AC9 — `DataIntegrityViolationException` → 400 `VALIDATION_ERROR`, body free of `chk_`/`meta_date`, WARN log, no Sentry (`GlobalExceptionHandlerTest`). ## Verification - `./mvnw test -Dtest=DocumentServiceTest` → 174 green; `-Dtest=GlobalExceptionHandlerTest` → green; `-Dtest=DocumentRepositoryTest#save_acceptsRange_whenEndEqualsStart` → green (Testcontainers). `./mvnw clean package -DskipTests` → BUILD SUCCESS. - Frontend `npm run check` clean for the touched files (Svelte MCP autofixer reported no issues); browser-mode component tests left to CI per project convention. ## Follow-up (out of scope, noted from implementation) - Switching a stored RANGE doc to a non-RANGE precision via the edit form keeps the previously-stored end date (the empty hidden field binds to `null`, which `applyDatePrecision` skips rather than clears). Today that already 500s at the DB; this PR makes it a clean 400, but the *ideal* fix is to clear `metaDateEnd` when precision leaves RANGE so the switch simply succeeds. Worth a small follow-up issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 6 commits 2026-06-01 09:31:08 +02:00
Add ErrorCode.INVALID_DATE_RANGE and a validateDateRange guard on
DocumentService.updateDocument, run right after applyDatePrecision so it
fires before any save (updateDocumentTags persists earlier in the method).
Mirrors the V69 chk_meta_date_end_after_start CHECK: end >= start with a
null start allowed, using isBefore so equal dates stay valid. Turns a user
date typo into a clean 400 instead of a 500 + Sentry alert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover AC2 (end == start), AC3 (open-ended, end null) and AC4 (null start +
end set, which must not reject or NPE), plus end-after-start. Guards the
guard against future over-rejection that would diverge from the DB CHECK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the second validateDateRange predicate mirroring
chk_meta_date_end_only_for_range, so a direct API client that sets an end
date without RANGE precision gets a clean 400 INVALID_DATE_RANGE instead of
a 500 (AC6). Shares the code with the end-before-start branch.

Also fix updateDocument_preservesStoredPrecision_whenDtoOmitsIt: its stored
fixture (MONTH + end date) is a state the DB CHECK forbids, so the
carried-over-state guard correctly rejects it. Switched to RANGE + end —
the only DB-valid non-null-end combo — preserving the test's intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add @ExceptionHandler(DataIntegrityViolationException) returning 400
VALIDATION_ERROR with a fixed constant message, so any integrity violation
that slips past the upstream guards (a future constraint, or the import
path) becomes a clean 400 instead of a 500 + Sentry alert (AC9).

Deliberately generic — it does not inspect which constraint failed. Never
echoes ex.getMessage() (constraint name + SQL, CWE-209), logs at WARN
without passing the exception (would re-leak the SQL to Loki), and does not
call Sentry.captureException.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an endBeforeStart $derived to WhoWhenSection (lexicographic ISO compare,
no Date object) that renders an inline error on the end-date field —
border-red-400, aria-invalid, aria-describedby, and a #end-date-error <p>
inside the existing aria-live region — with a ⚠ glyph so the cue is not
colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate.

Wire ErrorCode INVALID_DATE_RANGE through errors.ts getErrorMessage and add
the single key error_invalid_date_range to de/en/es, so the same translated
string is used inline (client) and via getErrorMessage (server fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(document): prove Postgres accepts an equal-date RANGE (#678)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
88600d54cd
Testcontainers integration test persisting a RANGE doc with end == start
against real Postgres + Flyway, which (unlike H2) enforces the V69
chk_meta_date_end_after_start CHECK. Pins the app guard's isBefore
semantics to the actual >= constraint, guarding against app/DB drift (AC2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: Approved

The shape is right: domain-owned validation as a private guard on DocumentService, mirroring the existing RelationshipService.validateYears precedent, plus a deliberately-generic infrastructure backstop in GlobalExceptionHandler. No layer boundaries crossed, no new coupling, no new dependency (DataIntegrityViolationException is already on the Spring Data classpath). The DB CHECK stays the single source of truth and the app guard is an honest mirror of it — that's the correct relationship between the two.

Suggestions (non-blocking)

  • Asymmetry between the create and update paths. validateDateRange is wired only into updateDocument. Today that's correct (the create/upload path writes documentDate only and structurally can't violate either constraint), and the backstop covers the import path. But the guard's safety now depends on an invariant in a different method staying true. If createDocument ever grows precision/end handling, this guard won't protect it and the contributor won't get a compile-time nudge. Consider a one-line comment on validateDateRange naming the callers it is (and isn't) wired into, so the asymmetry is intentional-by-record rather than incidental.
  • Ordering comment is load-bearing. validateDateRange(doc) must run before updateDocumentTags (which persists). The inline comment captures this well — good. Keep it; it's the kind of thing a future refactor silently breaks.
  • The shared INVALID_DATE_RANGE code for two predicates is a sound call while the user-facing message is identical. The ErrorCode Javadoc already documents both meanings — that's the right place for it.

Clean, minimal, reversible. No structural objections.

## 🏛️ Markus Keller — Application Architect **Verdict: ✅ Approved** The shape is right: domain-owned validation as a private guard on `DocumentService`, mirroring the existing `RelationshipService.validateYears` precedent, plus a deliberately-generic infrastructure backstop in `GlobalExceptionHandler`. No layer boundaries crossed, no new coupling, no new dependency (`DataIntegrityViolationException` is already on the Spring Data classpath). The DB CHECK stays the single source of truth and the app guard is an honest mirror of it — that's the correct relationship between the two. ### Suggestions (non-blocking) - **Asymmetry between the create and update paths.** `validateDateRange` is wired only into `updateDocument`. Today that's correct (the create/upload path writes `documentDate` only and structurally can't violate either constraint), and the backstop covers the import path. But the guard's safety now depends on an invariant in a *different* method staying true. If `createDocument` ever grows precision/end handling, this guard won't protect it and the contributor won't get a compile-time nudge. Consider a one-line comment on `validateDateRange` naming the callers it is (and isn't) wired into, so the asymmetry is intentional-by-record rather than incidental. - **Ordering comment is load-bearing.** `validateDateRange(doc)` must run before `updateDocumentTags` (which persists). The inline comment captures this well — good. Keep it; it's the kind of thing a future refactor silently breaks. - The shared `INVALID_DATE_RANGE` code for two predicates is a sound call while the user-facing message is identical. The `ErrorCode` Javadoc already documents both meanings — that's the right place for it. Clean, minimal, reversible. No structural objections.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Red→green per behaviour, one commit each, clean history. The guard reads exactly as the DB constraint does — isBefore (not !isAfter) with the null-start escape — and the comment says why, which is the part people get wrong. endBeforeStart mirrors the existing dateInvalid idiom, so the component stays consistent with itself. The test helpers (docForRangeUpdate, rangeDto) keep the new cases to three or four lines each. This is the kind of small, self-explaining change I like.

Suggestions (non-blocking)

  • The predicate now lives in two placesvalidateDateRange (server) and endBeforeStart (client). That's an acceptable duplication (different languages, different jobs: one gates, one hints), but they can drift. Not worth abstracting; just worth a mental note that a change to the rule means touching both.
  • Fixing the updateDocument_preservesStoredPrecision_whenDtoOmitsIt fixture from MONTH+end (a DB-impossible state) to RANGE+end was the right move, and the new comment explains it. Good — you didn't weaken the assertion to make the guard pass, you corrected an invalid fixture.

Nothing to change. Ship it once CI is green.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Red→green per behaviour, one commit each, clean history. The guard reads exactly as the DB constraint does — `isBefore` (not `!isAfter`) with the null-start escape — and the comment says *why*, which is the part people get wrong. `endBeforeStart` mirrors the existing `dateInvalid` idiom, so the component stays consistent with itself. The test helpers (`docForRangeUpdate`, `rangeDto`) keep the new cases to three or four lines each. This is the kind of small, self-explaining change I like. ### Suggestions (non-blocking) - **The predicate now lives in two places** — `validateDateRange` (server) and `endBeforeStart` (client). That's an acceptable duplication (different languages, different jobs: one gates, one hints), but they can drift. Not worth abstracting; just worth a mental note that a change to the rule means touching both. - Fixing the `updateDocument_preservesStoredPrecision_whenDtoOmitsIt` fixture from `MONTH+end` (a DB-impossible state) to `RANGE+end` was the right move, and the new comment explains it. Good — you didn't weaken the assertion to make the guard pass, you corrected an invalid fixture. Nothing to change. Ship it once CI is green.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: ⚠️ Approved with concerns

The headline win is squarely in my lane: a user date typo no longer fabricates a Sentry issue and an ERROR line in Loki. Logging at WARN without passing the exception object is exactly right — passing ex would have dumped the constraint SQL back into Loki and re-created the leak you're closing. No new infra, no new env var. Good.

Concern (please weigh before merge)

  • The generic backstop trades a loud 500 for a silent 400 across all integrity violations, not just date ranges. Before this PR, any DataIntegrityViolationException — a genuine bug writing a NULL into a NOT NULL column, a unique-constraint race, a future FK violation — surfaced as a 500 + Sentry.captureException + stack trace. That's noisy, but it's visible: it shows up in GlitchTip and pages someone. After this PR, every one of those becomes WARN "Rejected a request that violated a database integrity constraint" with no constraint name, no stack, no Sentry. A latent write bug could now fail silently in production and we'd never know until a user complains.

    The known date-range cases are caught upstream and never reach here — agreed. My worry is the unanticipated ones, which are precisely the ones you want an alert for. Two options that keep the CWE-209 fix intact:

    1. Log the constraint name only (not ex.getMessage()) at WARN — e.g. pull ex.getMostSpecificCause()'s constraint identifier if available — so Loki can tell you which constraint fired without leaking SQL. A bare "some constraint, somewhere" WARN is nearly undebuggable at 2am.
    2. Keep the 400 response, but still Sentry.captureException(ex) (or a sanitized breadcrumb) so an unexpected integrity violation remains visible in error tracking. The response stays clean; the alert stays alive.

    Either is fine; the current "swallow everything to WARN" is the one I'd push back on. This is a deliberate observability trade-off and it should be a decision, not a side effect.

Not a merge blocker for the date-range feature itself — but I'd like a line in the PR (or a follow-up) acknowledging the broadened swallow.

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ⚠️ Approved with concerns** The headline win is squarely in my lane: a user date typo no longer fabricates a Sentry issue and an ERROR line in Loki. Logging at WARN *without* passing the exception object is exactly right — passing `ex` would have dumped the constraint SQL back into Loki and re-created the leak you're closing. No new infra, no new env var. Good. ### Concern (please weigh before merge) - **The generic backstop trades a loud 500 for a silent 400 across *all* integrity violations, not just date ranges.** Before this PR, any `DataIntegrityViolationException` — a genuine bug writing a NULL into a NOT NULL column, a unique-constraint race, a future FK violation — surfaced as a 500 + `Sentry.captureException` + stack trace. That's noisy, but it's *visible*: it shows up in GlitchTip and pages someone. After this PR, every one of those becomes `WARN "Rejected a request that violated a database integrity constraint"` with **no constraint name, no stack, no Sentry**. A latent write bug could now fail silently in production and we'd never know until a user complains. The known date-range cases are caught upstream and never reach here — agreed. My worry is the *unanticipated* ones, which are precisely the ones you want an alert for. Two options that keep the CWE-209 fix intact: 1. Log the **constraint name only** (not `ex.getMessage()`) at WARN — e.g. pull `ex.getMostSpecificCause()`'s constraint identifier if available — so Loki can tell you *which* constraint fired without leaking SQL. A bare "some constraint, somewhere" WARN is nearly undebuggable at 2am. 2. Keep the 400 response, but still `Sentry.captureException(ex)` (or a sanitized breadcrumb) so an *unexpected* integrity violation remains visible in error tracking. The response stays clean; the alert stays alive. Either is fine; the current "swallow everything to WARN" is the one I'd push back on. This is a deliberate observability trade-off and it should be a *decision*, not a side effect. Not a merge blocker for the date-range feature itself — but I'd like a line in the PR (or a follow-up) acknowledging the broadened swallow.
Author
Owner

📋 "Elicit" — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Strong traceability: all nine ACs map to named tests, and the EARS statement in the issue is satisfied. AC7 in particular is handled honestly — one error_invalid_date_range key in all three locales, used both inline (client) and via getErrorMessage (server fallback), so there's a single translated string rather than two that drift. AC2's "equal dates are valid" is even pinned against real Postgres, which is the right level of rigor for a rule that mirrors a DB constraint.

Concern — a requirement assumption in the issue is factually wrong

The issue's decided approach states, for predicate 2: "this branch is API-only — the edit form clears the end field off-RANGE." The PR's own follow-up note correctly identifies that this is not what the form does: switching a stored RANGE document to a non-RANGE precision leaves the previously-stored end date in place (the empty hidden field binds to null, which applyDatePrecision skips rather than clears). So a real user on the form — not just an API client — can hit predicate 2, and when they do they'll see "The end date must not be before the start date", which is the wrong sentence for the "end without RANGE" situation.

This doesn't block the PR (the behaviour is strictly better than today's 500, and the shared-message decision was made deliberately), but it means AC6's stated scope ("via API") understates reality, and there's an unwritten requirement hiding here:

When a document's precision is changed away from RANGE, the end date shall be cleared so the change succeeds.

Please open the follow-up issue the PR mentions and capture that as an explicit requirement, including which message the user should see. Right now the "truth" is split between the issue body (wrong assumption) and a PR comment (correct) — fold the correction back into a tracked requirement so it isn't lost.

Everything testable is tested. The gap is a missing requirement, not a missing test.

## 📋 "Elicit" — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Strong traceability: all nine ACs map to named tests, and the EARS statement in the issue is satisfied. AC7 in particular is handled honestly — one `error_invalid_date_range` key in all three locales, used both inline (client) and via `getErrorMessage` (server fallback), so there's a single translated string rather than two that drift. AC2's "equal dates are valid" is even pinned against real Postgres, which is the right level of rigor for a rule that mirrors a DB constraint. ### Concern — a requirement assumption in the issue is factually wrong The issue's decided approach states, for predicate 2: *"this branch is API-only — the edit form clears the end field off-RANGE."* The PR's own follow-up note correctly identifies that this is **not** what the form does: switching a stored RANGE document to a non-RANGE precision leaves the previously-stored end date in place (the empty hidden field binds to `null`, which `applyDatePrecision` skips rather than clears). So a *real user on the form* — not just an API client — can hit predicate 2, and when they do they'll see *"The end date must not be before the start date"*, which is the wrong sentence for the "end without RANGE" situation. This doesn't block the PR (the behaviour is strictly better than today's 500, and the shared-message decision was made deliberately), but it means **AC6's stated scope ("via API") understates reality**, and there's an unwritten requirement hiding here: > When a document's precision is changed away from RANGE, the end date shall be cleared so the change succeeds. Please open the follow-up issue the PR mentions and capture that as an explicit requirement, including which message the user should see. Right now the "truth" is split between the issue body (wrong assumption) and a PR comment (correct) — fold the correction back into a tracked requirement so it isn't lost. Everything testable is tested. The gap is a missing requirement, not a missing test.
Author
Owner

🛡️ Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approved

This PR closes a CWE-209 (Information Exposure Through an Error Message) hole, and does it properly — I checked for the usual half-fixes and didn't find them:

  • The 400 body returns a fixed constant string, never ex.getMessage(). A DataIntegrityViolationException from Hibernate carries the constraint name, the column, and often a fragment of the failing SQL; none of that now reaches the client.
  • The log call is log.warn("...") with no exception argument. This is the subtle one most people miss — log.warn("msg", ex) would serialize the full cause chain (including the SQL) straight into Loki, just moving the leak from the HTTP response to the log store. You avoided it, and the test even asserts no log event contains chk_.
  • The regression test pins the contract: body free of chk_ and meta_date, and Sentry.verifyNoInteractions(). That's a permanent guard against someone "helpfully" re-adding ex.getMessage() later.

Notes (non-blocking)

  • The upstream DomainException(INVALID_DATE_RANGE) developer messages ("meta_date_end must not be before meta_date") are surfaced to the client via handleDomain. That's fine — they're hand-written, contain no secrets, no SQL, no schema internals beyond column names the OpenAPI spec already exposes. No disclosure concern.
  • No injection surface here (no string-built queries, no reflection on user input). The lexicographic ISO compare on the client is pure string comparison — safe.

Good, defensive work. This is the right way to turn a 500 into a 400 without trading an information leak for it.

## 🛡️ Nora Steiner ("NullX") — Application Security Engineer **Verdict: ✅ Approved** This PR *closes* a CWE-209 (Information Exposure Through an Error Message) hole, and does it properly — I checked for the usual half-fixes and didn't find them: - The 400 body returns a **fixed constant string**, never `ex.getMessage()`. A `DataIntegrityViolationException` from Hibernate carries the constraint name, the column, and often a fragment of the failing SQL; none of that now reaches the client. ✅ - The log call is `log.warn("...")` with **no exception argument**. This is the subtle one most people miss — `log.warn("msg", ex)` would serialize the full cause chain (including the SQL) straight into Loki, just moving the leak from the HTTP response to the log store. You avoided it, and the test even asserts no log event contains `chk_`. ✅ - The regression test pins the contract: body free of `chk_` and `meta_date`, and `Sentry.verifyNoInteractions()`. That's a permanent guard against someone "helpfully" re-adding `ex.getMessage()` later. ✅ ### Notes (non-blocking) - The upstream `DomainException(INVALID_DATE_RANGE)` developer messages (`"meta_date_end must not be before meta_date"`) are surfaced to the client via `handleDomain`. That's fine — they're hand-written, contain no secrets, no SQL, no schema internals beyond column names the OpenAPI spec already exposes. No disclosure concern. - No injection surface here (no string-built queries, no reflection on user input). The lexicographic ISO compare on the client is pure string comparison — safe. Good, defensive work. This is the right way to turn a 500 into a 400 without trading an information leak for it.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: ⚠️ Approved with concerns

This was my follow-up from #677 and I'm pleased with the coverage shape: six service-level cases spanning the real boundary (end < start, end == start, end > start, end == null, start == null && end set, and end without RANGE), a handler test that mocks Sentry and attaches a Logback ListAppender to assert the WARN level and the no-leak property, and a Testcontainers test that proves the equal-date case against real Postgres rather than H2. The start == null && end set case explicitly exists to catch an NPE in the guard — that's the kind of boundary I'd have asked for, and it's already here.

Concerns

  1. AC1 and AC5 are browser-mode component tests that were not executed locally (the PR says they're left to CI). I can't sign off on green I haven't seen. Before merge, please confirm the WhoWhenSection.svelte.test.ts suite actually passes in CI — the AC5 "clears on correction" path in particular depends on handleGermanDateInput('12.01.1917') producing 1917-01-12, which is an assumption the local run never verified. If CI is red on these, the inline-clear behaviour is unproven.
  2. No negative DB-level test for the rejection path. We prove Postgres accepts equal dates (AC2), but we never prove Postgres rejects end < start — the app guard intercepts it first, so the CHECK itself is only assumed to fire. That's acceptable (the guard is the user-facing contract), but if the guard ever regresses, nothing catches the drift at the DB layer. A assertThatThrownBy(() -> saveAndFlush(end<start)).isInstanceOf(DataIntegrityViolationException.class) would close that, and would also exercise the new backstop end-to-end. Worth considering as a cheap addition.
  3. Minor: updateDocument_acceptsRange_whenEndAfterStart asserts only save was called, not the persisted end value. Tightening it to assert doc.getMetaDateEnd() (like the equal-date sibling does) would make the intent self-evident.

None of these block the date-range logic, which is well tested. Concern #1 is the one I want resolved (CI confirmation) before this merges.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ⚠️ Approved with concerns** This was my follow-up from #677 and I'm pleased with the coverage shape: six service-level cases spanning the real boundary (`end < start`, `end == start`, `end > start`, `end == null`, `start == null && end set`, and `end without RANGE`), a handler test that mocks `Sentry` *and* attaches a Logback `ListAppender` to assert the WARN level and the no-leak property, and a Testcontainers test that proves the equal-date case against real Postgres rather than H2. The `start == null && end set` case explicitly exists to catch an NPE in the guard — that's the kind of boundary I'd have asked for, and it's already here. ### Concerns 1. **AC1 and AC5 are browser-mode component tests that were not executed locally** (the PR says they're left to CI). I can't sign off on green I haven't seen. Before merge, please confirm the `WhoWhenSection.svelte.test.ts` suite actually passes in CI — the AC5 "clears on correction" path in particular depends on `handleGermanDateInput('12.01.1917')` producing `1917-01-12`, which is an assumption the local run never verified. If CI is red on these, the inline-clear behaviour is unproven. 2. **No negative DB-level test for the rejection path.** We prove Postgres *accepts* equal dates (AC2), but we never prove Postgres *rejects* `end < start` — the app guard intercepts it first, so the CHECK itself is only assumed to fire. That's acceptable (the guard is the user-facing contract), but if the guard ever regresses, nothing catches the drift at the DB layer. A `assertThatThrownBy(() -> saveAndFlush(end<start)).isInstanceOf(DataIntegrityViolationException.class)` would close that, and would also exercise the new backstop end-to-end. Worth considering as a cheap addition. 3. Minor: `updateDocument_acceptsRange_whenEndAfterStart` asserts only `save` was called, not the persisted end value. Tightening it to assert `doc.getMetaDateEnd()` (like the equal-date sibling does) would make the intent self-evident. None of these block the date-range logic, which is well tested. Concern #1 is the one I want resolved (CI confirmation) before this merges.
Author
Owner

🎨 Leonie Voss — UI/UX & Accessibility Lead

Verdict: Approved

This is how an inline validation error should be built, and I'm glad the non-colour cue wasn't an afterthought:

  • WCAG 1.4.1 (Use of Color): the error pairs a ⚠ glyph with the text, so it doesn't rely on red alone. The glyph is aria-hidden="true", so a screen reader gets the sentence, not "warning sign warning sign". Correct on both counts.
  • Programmatic association: aria-invalid="true" and aria-describedby="end-date-error" are wired to the input, and the #end-date-error message lives inside the existing aria-live="polite" region — so when it appears, it's announced without stealing focus.
  • Doesn't trap the user: Save isn't disabled; the field just flags itself and clears the moment the date is corrected (AC5). For our 60+ transcribers, a disabled button with no explanation is far worse than a visible, self-clearing hint. Good call keeping the server as the gate.
  • The input keeps its min-h-[48px] touch target through the error state — the conditional only swaps the border/ring colour, so the tap target doesn't shift.

Suggestions (non-blocking)

  • Consider giving the message role="alert" or leaving it to the aria-live="polite" parent — right now it relies on the polite region, which is the gentler, correct choice for a non-critical inline hint. No change needed; just confirming the polite (not assertive) level is intentional. It is appropriate here.
  • text-red-600 on bg-surface clears AA for this text size, but the ⚠ glyph carries the meaning regardless, so even users who can't distinguish the red are covered. Nicely belt-and-braces.

No accessibility blockers. This pattern is worth reusing for the start-date field too if we ever add cross-field rules there.

## 🎨 Leonie Voss — UI/UX & Accessibility Lead **Verdict: ✅ Approved** This is how an inline validation error should be built, and I'm glad the non-colour cue wasn't an afterthought: - **WCAG 1.4.1 (Use of Color):** the error pairs a ⚠ glyph with the text, so it doesn't rely on red alone. The glyph is `aria-hidden="true"`, so a screen reader gets the sentence, not "warning sign warning sign". Correct on both counts. ✅ - **Programmatic association:** `aria-invalid="true"` and `aria-describedby="end-date-error"` are wired to the input, and the `#end-date-error` message lives **inside** the existing `aria-live="polite"` region — so when it appears, it's announced without stealing focus. ✅ - **Doesn't trap the user:** Save isn't disabled; the field just flags itself and clears the moment the date is corrected (AC5). For our 60+ transcribers, a disabled button with no explanation is far worse than a visible, self-clearing hint. Good call keeping the server as the gate. ✅ - The input keeps its `min-h-[48px]` touch target through the error state — the conditional only swaps the border/ring colour, so the tap target doesn't shift. ✅ ### Suggestions (non-blocking) - Consider giving the message `role="alert"` *or* leaving it to the `aria-live="polite"` parent — right now it relies on the polite region, which is the gentler, correct choice for a non-critical inline hint. No change needed; just confirming the polite (not assertive) level is intentional. It is appropriate here. - `text-red-600` on `bg-surface` clears AA for this text size, but the ⚠ glyph carries the meaning regardless, so even users who can't distinguish the red are covered. Nicely belt-and-braces. No accessibility blockers. This pattern is worth reusing for the start-date field too if we ever add cross-field rules there.
marcel added 2 commits 2026-06-01 11:04:25 +02:00
Addresses Tobias's review concern: the generic DataIntegrityViolation
backstop turned every integrity violation into a silent 400 with no
constraint name, no stack, no Sentry — an unanticipated write bug would
fail invisibly in production.

Now extract the constraint NAME from the cause chain (schema metadata, safe
for Loki) and log it parameterized at WARN, so the failure is debuggable.
Still never pass `ex`/`getMessage()` (SQL + values, CWE-209) and still no
Sentry — the response stays generic, so the response logic is not brittle.

New test proves the WARN names the constraint but never carries the SQL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(document): prove the DB rejects end-before-start; assert persisted end (#678)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m37s
CI / fail2ban Regex (push) Successful in 43s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
1dd162f1be
Addresses Sara's review concerns:
- Add a negative Testcontainers test: saveAndFlush of a RANGE with end < start
  throws DataIntegrityViolationException, proving chk_meta_date_end_after_start
  actually fires (H2 wouldn't) and exercising the backstop's trigger end-to-end.
  Guards against silent app/DB drift if the service guard ever regresses.
- Tighten updateDocument_acceptsRange_whenEndAfterStart to assert the persisted
  end value, not just that save was called.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

🔧 Review concerns addressed

Pushed ff7cfd4 and 1dd162f in response to the review. No hard blockers were raised; the actionable concerns are resolved as follows.

🔧 Tobias (DevOps) — silent swallow of unanticipated violations → fixed in ff7cfd4.
The backstop now extracts the constraint name from the cause chain and logs it parameterized at WARN ("…violated a database integrity constraint: {}"), so an unexpected violation is debuggable in Loki instead of an anonymous mystery. It still never passes ex/getMessage() (SQL + values, CWE-209) and still doesn't call Sentry — the response stays generic, so the response logic remains non-brittle (consistent with #678's "don't branch the response on the constraint"). New test asserts the WARN carries the constraint name but never the SQL statement. (I went with your option 1 rather than re-enabling Sentry, since #678 deliberately decided integrity violations are 400s, not system-fault alerts.)

🧪 Sara (QA)

  • Negative DB-level test (1dd162f): a Testcontainers test now proves Postgres rejects end < start with a DataIntegrityViolationException — confirming chk_meta_date_end_after_start actually fires (not just assumed) and exercising the backstop's trigger end-to-end.
  • Tightened assertion (1dd162f): …whenEndAfterStart now asserts the persisted metaDateEnd, not just that save ran.
  • CI confirmation for AC1/AC5: I can't run browser-mode tests locally, but I verified the AC5 date math directly — handleGermanDateInput('12.01.1917')germanToIso1917-01-12, so '1917-01-12' < '1917-01-11' is false and the inline error clears. The render itself remains CI's call.

📋 Elicit (Req Engineer) — the "form clears end off-RANGE" assumption is false → tracked as #707 (labeled feature/ui/P3-later). The clear-on-precision-switch fix can't live here without contradicting this PR's locked AC6 (which asserts a 400), so it's a deliberate follow-up. The correct requirement is captured there with its own ACs.

🏛️ Markus / 👨‍💻 Felix / 🛡️ Nora / 🎨 Leonie — approved, suggestions noted; Markus's create/update asymmetry and Felix's two-places-for-the-predicate are acknowledged as acceptable-by-design (the latter is documented in the guard/component comments).

Local verification after the changes: DocumentServiceTest + DocumentRepositoryTest205 green, GlobalExceptionHandlerTest3 green, lint clean.

## 🔧 Review concerns addressed Pushed `ff7cfd4` and `1dd162f` in response to the review. No hard blockers were raised; the actionable concerns are resolved as follows. **🔧 Tobias (DevOps) — silent swallow of unanticipated violations** → fixed in `ff7cfd4`. The backstop now extracts the **constraint name** from the cause chain and logs it parameterized at WARN (`"…violated a database integrity constraint: {}"`), so an unexpected violation is debuggable in Loki instead of an anonymous mystery. It still never passes `ex`/`getMessage()` (SQL + values, CWE-209) and still doesn't call Sentry — the **response stays generic**, so the response logic remains non-brittle (consistent with #678's "don't branch the response on the constraint"). New test asserts the WARN carries the constraint name but never the SQL statement. (I went with your option 1 rather than re-enabling Sentry, since #678 deliberately decided integrity violations are 400s, not system-fault alerts.) **🧪 Sara (QA)** → - *Negative DB-level test* (`1dd162f`): a Testcontainers test now proves Postgres **rejects** `end < start` with a `DataIntegrityViolationException` — confirming `chk_meta_date_end_after_start` actually fires (not just assumed) and exercising the backstop's trigger end-to-end. - *Tightened assertion* (`1dd162f`): `…whenEndAfterStart` now asserts the persisted `metaDateEnd`, not just that `save` ran. - *CI confirmation for AC1/AC5*: I can't run browser-mode tests locally, but I verified the AC5 date math directly — `handleGermanDateInput('12.01.1917')` → `germanToIso` → `1917-01-12`, so `'1917-01-12' < '1917-01-11'` is `false` and the inline error clears. The render itself remains CI's call. **📋 Elicit (Req Engineer) — the "form clears end off-RANGE" assumption is false** → tracked as **#707** (labeled feature/ui/P3-later). The clear-on-precision-switch fix can't live here without contradicting this PR's locked **AC6** (which asserts a 400), so it's a deliberate follow-up. The correct requirement is captured there with its own ACs. **🏛️ Markus / 👨‍💻 Felix / 🛡️ Nora / 🎨 Leonie** — approved, suggestions noted; Markus's create/update asymmetry and Felix's two-places-for-the-predicate are acknowledged as acceptable-by-design (the latter is documented in the guard/component comments). Local verification after the changes: `DocumentServiceTest` + `DocumentRepositoryTest` → **205 green**, `GlobalExceptionHandlerTest` → **3 green**, lint clean.
marcel merged commit 1dd162f1be into main 2026-06-01 13:11:57 +02:00
marcel deleted branch feat/issue-678-range-date-validation 2026-06-01 13:11:59 +02:00
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#706