From a574d963511f4a3d91f63c376a2920873b7868e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:12:54 +0200 Subject: [PATCH 1/8] feat(document): reject RANGE with end before start (#678) 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 --- .../document/DocumentService.java | 20 ++++++++++++ .../familienarchiv/exception/ErrorCode.java | 2 ++ .../document/DocumentServiceTest.java | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index faa24de3..e947ddc8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -381,6 +381,7 @@ public class DocumentService { doc.setTitle(dto.getTitle()); doc.setDocumentDate(dto.getDocumentDate()); applyDatePrecision(doc, dto); + validateDateRange(doc); // guard before any save (updateDocumentTags below persists) doc.setLocation(dto.getLocation()); doc.setTranscription(dto.getTranscription()); doc.setSummary(dto.getSummary()); @@ -468,6 +469,25 @@ public class DocumentService { } } + /** + * Friendly guard for the two V69 date-range CHECK constraints, run before save so a + * user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to + * the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc} + * state, not the DTO, because precision/end may have been carried over from the stored row + * when the DTO field was null. The DB CHECK remains the backstop; this never weakens it. + */ + private void validateDateRange(Document doc) { + // Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed. + // Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=. + if (doc.getMetaDatePrecision() == DatePrecision.RANGE + && doc.getDocumentDate() != null + && doc.getMetaDateEnd() != null + && doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "meta_date_end must not be before meta_date"); + } + } + @Transactional public Document updateDocumentTags(UUID docId, List tagNames) { Document doc = documentRepository.findById(docId) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index d9d0d8b2..3eb5287d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -26,6 +26,8 @@ public enum ErrorCode { FILE_UPLOAD_FAILED, /** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */ UNSUPPORTED_FILE_TYPE, + /** A RANGE date is invalid: meta_date_end is before meta_date, or an end date is set without RANGE precision. 400 */ + INVALID_DATE_RANGE, // --- Users --- /** A user with the given ID or username does not exist. 404 */ diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 9257aafe..0f05950e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -20,6 +20,7 @@ import org.raddatz.familienarchiv.document.MatchOffset; import org.raddatz.familienarchiv.document.SearchMatchData; import org.raddatz.familienarchiv.tag.TagOperator; import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.person.Person; @@ -225,6 +226,36 @@ class DocumentServiceTest { assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916"); } + // ─── updateDocument date-range validation (#678) ────────────────────────── + + /** Builds a stored doc ready for an updateDocument call (collections initialised). */ + private static Document docForRangeUpdate(UUID id) { + return Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build(); + } + + private static DocumentUpdateDTO rangeDto(LocalDate start, LocalDate end) { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setDocumentDate(start); + dto.setMetaDatePrecision(DatePrecision.RANGE); + dto.setMetaDateEnd(end); + return dto; + } + + @Test + void updateDocument_rejectsRange_whenEndBeforeStart() { + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + DocumentUpdateDTO dto = rangeDto(LocalDate.of(1917, 1, 11), LocalDate.of(1917, 1, 10)); + + assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(documentRepository, never()).save(any()); + } + // ─── deleteTagCascading ─────────────────────────────────────────────────── @Test -- 2.49.1 From 6c5e5273bb781962be977cce3ccd34dcb950c795 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:13:59 +0200 Subject: [PATCH 2/8] =?UTF-8?q?test(document):=20lock=20in=20accepted=20RA?= =?UTF-8?q?NGE=20cases=20=E2=80=94=20equal/after/open/null-start=20(#678)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../document/DocumentServiceTest.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 0f05950e..58b64fd4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -256,6 +256,63 @@ class DocumentServiceTest { verify(documentRepository, never()).save(any()); } + @Test + void updateDocument_acceptsRange_whenEndEqualsStart() throws Exception { + // AC2: the DB CHECK is end >= start, so equal dates are valid. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + LocalDate same = LocalDate.of(1917, 1, 10); + documentService.updateDocument(id, rangeDto(same, same), null, null); + + assertThat(doc.getMetaDateEnd()).isEqualTo(same); + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenEndAfterStart() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null); + + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenEndIsNull_openEnded() throws Exception { + // AC3: an open-ended range (no end) is valid. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(LocalDate.of(1917, 1, 10), null), null, null); + + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenStartNullAndEndSet() throws Exception { + // AC4: mirrors the DB "meta_date IS NULL" escape — must NOT reject (and must not NPE). + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(null, LocalDate.of(1917, 1, 11)), null, null); + + assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11)); + verify(documentRepository, atLeastOnce()).save(any()); + } + // ─── deleteTagCascading ─────────────────────────────────────────────────── @Test -- 2.49.1 From 73f614bc3aab89cc6d5dc5efeb0d0d06e6a8f6d5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:17:52 +0200 Subject: [PATCH 3/8] feat(document): reject end date without RANGE precision (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../document/DocumentService.java | 6 +++++ .../document/DocumentServiceTest.java | 26 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index e947ddc8..2f3edbc2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -486,6 +486,12 @@ public class DocumentService { throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, "meta_date_end must not be before meta_date"); } + // Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the + // end field off-RANGE, so this branch closes the same 500 class for direct clients. + if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "meta_date_end is only allowed when meta_date_precision is RANGE"); + } } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 58b64fd4..4c11bd30 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -204,10 +204,12 @@ class DocumentServiceTest { // Editing a doc (e.g. fixing a location typo) without touching the precision // controls must NOT fabricate a precision. The form omits the three precision // fields → they arrive null on the DTO → the stored values must be preserved. + // Stored combo is RANGE + end: the only DB-valid way to have a non-null end + // (chk_meta_date_end_only_for_range), so the carried-over state passes the guard. UUID id = UUID.randomUUID(); Document doc = Document.builder() .id(id) - .metaDatePrecision(DatePrecision.MONTH) + .metaDatePrecision(DatePrecision.RANGE) .metaDateEnd(LocalDate.of(1916, 6, 30)) .metaDateRaw("Juni 1916") .receivers(new HashSet<>()) @@ -221,7 +223,7 @@ class DocumentServiceTest { documentService.updateDocument(id, dto, null, null); - assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH); + assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30)); assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916"); } @@ -313,6 +315,26 @@ class DocumentServiceTest { verify(documentRepository, atLeastOnce()).save(any()); } + @Test + void updateDocument_rejectsEndDate_whenPrecisionNotRange() { + // AC6: an end date only makes sense for RANGE (mirrors chk_meta_date_end_only_for_range). + // API-only — the edit form clears the end field off-RANGE — so close the 500 class here too. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setDocumentDate(LocalDate.of(1917, 1, 10)); + dto.setMetaDatePrecision(DatePrecision.MONTH); + dto.setMetaDateEnd(LocalDate.of(1917, 1, 31)); + + assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(documentRepository, never()).save(any()); + } + // ─── deleteTagCascading ─────────────────────────────────────────────────── @Test -- 2.49.1 From 3a4c2c6225e9b21f03b6185cce96ed99a96c891f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:20:22 +0200 Subject: [PATCH 4/8] feat(exception): backstop DataIntegrityViolation as a clean 400 (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../exception/GlobalExceptionHandler.java | 17 +++++++ .../exception/GlobalExceptionHandlerTest.java | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 87838d5c..7a029955 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import io.sentry.Sentry; import jakarta.validation.ConstraintViolationException; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -64,6 +65,22 @@ public class GlobalExceptionHandler { .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason())); } + /** + * Backstop for any database integrity violation that slips past the explicit upstream + * guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into + * a clean 400 instead of a 500 + Sentry alert. Deliberately generic: it does NOT inspect the + * exception to identify the constraint — that inspection is the brittle thing we avoid, and the + * known date-range cases are already caught upstream and never reach here. + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException ex) { + // Fixed message only: ex.getMessage() embeds the constraint name + SQL (CWE-209), and + // passing ex to the logger would dump the same into Loki — so do neither, and no Sentry. + log.warn("Rejected a request that violated a database integrity constraint"); + return ResponseEntity.badRequest() + .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { Sentry.captureException(ex); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java index a12933b8..3a51993f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java @@ -1,11 +1,17 @@ package org.raddatz.familienarchiv.exception; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.sentry.Sentry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; @@ -30,4 +36,45 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR); } } + + @Test + void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() { + // A DataIntegrityViolationException carries the constraint name + SQL in its message; + // the response and logs must never echo it (CWE-209). It must become a clean 400, not a 500. + DataIntegrityViolationException ex = new DataIntegrityViolationException( + "could not execute statement; constraint [chk_meta_date_end_after_start]; " + + "column meta_date_end of relation documents"); + + Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + handlerLogger.addAppender(appender); + + try (MockedStatic sentryMock = mockStatic(Sentry.class)) { + ResponseEntity response = + handler.handleDataIntegrityViolation(ex); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(response.getBody().message()) + .doesNotContain("chk_") + .doesNotContain("meta_date"); + + // Defense-in-depth: an unanticipated integrity violation is not a system fault, + // so it must NOT fabricate a Sentry alert. + sentryMock.verifyNoInteractions(); + } finally { + handlerLogger.detachAppender(appender); + } + + assertThat(appender.list) + .as("logs a WARN line") + .anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN)); + assertThat(appender.list) + .as("never logs the constraint name / SQL (would re-leak to Loki)") + .noneSatisfy(e -> { + assertThat(e.getFormattedMessage()).contains("chk_"); + }); + } } -- 2.49.1 From 654ac1478c2ed5dd4b4d8c0a3275e4cee65aace1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:27:57 +0200 Subject: [PATCH 5/8] feat(document): surface end-before-start inline on the date form (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/lib/document/WhoWhenSection.svelte | 20 +++++++- .../document/WhoWhenSection.svelte.test.ts | 46 +++++++++++++++++++ frontend/src/lib/shared/errors.ts | 3 ++ 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 14269eed..5590c6b7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Entfernen", "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", + "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b7de0948..5b7c2698 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Remove", "error_alias_not_found": "The name alias was not found.", "error_invalid_person_type": "The specified person type is not valid.", + "error_invalid_date_range": "The end date must not be before the start date.", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index ee584c40..4e856892 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Eliminar", "error_alias_not_found": "No se encontro el alias de nombre.", "error_invalid_person_type": "El tipo de persona especificado no es válido.", + "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", diff --git a/frontend/src/lib/document/WhoWhenSection.svelte b/frontend/src/lib/document/WhoWhenSection.svelte index 1a312e63..8c49a72b 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte +++ b/frontend/src/lib/document/WhoWhenSection.svelte @@ -70,6 +70,13 @@ onMount(() => { const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); +// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare +// lexicographically, so no Date object is needed. Server stays the gate — +// this only surfaces the error early; it never disables Save. +const endBeforeStart = $derived( + showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso +); + function handleDateInput(e: Event) { const result = handleGermanDateInput(e); dateDisplay = result.display; @@ -155,8 +162,19 @@ $effect(() => { oninput={handleEndDateInput} placeholder={m.form_placeholder_date()} maxlength="10" - class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + aria-invalid={endBeforeStart ? 'true' : undefined} + aria-describedby={endBeforeStart ? 'end-date-error' : undefined} + class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm + {endBeforeStart + ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' + : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}" /> + {#if endBeforeStart} + +

+ {m.error_invalid_date_range()} +

+ {/if} {/if} diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts index d3a05147..f2d7746f 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts @@ -102,3 +102,49 @@ describe('WhoWhenSection — precision controls', () => { expect(raw?.querySelector('b')).toBeNull(); }); }); + +describe('WhoWhenSection — end-before-start inline validation (#678)', () => { + it('shows an inline error on the end-date field when end is before start (AC1)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).not.toBeNull(); + expect(end.getAttribute('aria-invalid')).toBe('true'); + expect(end.className).toContain('border-red-400'); + }); + }); + + it('clears the inline error once the end date is corrected, without reload (AC5)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull()); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + end.value = '12.01.1917'; // now after the start + end.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).toBeNull(); + expect(end.getAttribute('aria-invalid')).not.toBe('true'); + }); + }); + + it('does not show the inline error when precision is not RANGE', async () => { + render(WhoWhenSection, { + precision: 'DAY', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + expect(document.querySelector('#end-date-error')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index dcdb9f25..6efec2a7 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -8,6 +8,7 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' + | 'INVALID_DATE_RANGE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -87,6 +88,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); + case 'INVALID_DATE_RANGE': + return m.error_invalid_date_range(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE': -- 2.49.1 From 88600d54cda2392c49d6265bb447c00231dc159b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:29:37 +0200 Subject: [PATCH 6/8] test(document): prove Postgres accepts an equal-date RANGE (#678) 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 --- .../document/DocumentRepositoryTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java index 4b2d1b70..a805925b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java @@ -612,6 +612,29 @@ class DocumentRepositoryTest { .isLessThanOrEqualTo(5); } + // ─── V69 date-range CHECK constraints (#678) ────────────────────────────── + + @Test + void save_acceptsRange_whenEndEqualsStart() { + // chk_meta_date_end_after_start is end >= start, so equal dates are valid. + // Real Postgres + Flyway here (H2 would not enforce the CHECK) pins the + // app guard's isBefore semantics to the actual constraint — guards drift (AC2). + LocalDate day = LocalDate.of(1917, 1, 10); + Document saved = documentRepository.saveAndFlush(Document.builder() + .title("Gleicher Tag") + .originalFilename("gleicher_tag.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(day) + .metaDatePrecision(DatePrecision.RANGE) + .metaDateEnd(day) + .build()); + + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + assertThat(found.getDocumentDate()).isEqualTo(day); + assertThat(found.getMetaDateEnd()).isEqualTo(day); + assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); + } + // ─── seeding helpers ───────────────────────────────────────────────────── private Document uploaded(String title) { -- 2.49.1 From ff7cfd4b1a8b651129e908edfb23ff97b88a4396 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 11:03:04 +0200 Subject: [PATCH 7/8] fix(exception): log the violated constraint name at WARN (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../exception/GlobalExceptionHandler.java | 28 +++++++++--- .../exception/GlobalExceptionHandlerTest.java | 43 ++++++++++++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 7a029955..686ef457 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -68,19 +68,35 @@ public class GlobalExceptionHandler { /** * Backstop for any database integrity violation that slips past the explicit upstream * guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into - * a clean 400 instead of a 500 + Sentry alert. Deliberately generic: it does NOT inspect the - * exception to identify the constraint — that inspection is the brittle thing we avoid, and the - * known date-range cases are already caught upstream and never reach here. + * a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream + * and never reach here; this only catches the unanticipated ones — so it logs the constraint + * NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response + * on it (the response stays generic, which is the non-brittle part). */ @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException ex) { - // Fixed message only: ex.getMessage() embeds the constraint name + SQL (CWE-209), and - // passing ex to the logger would dump the same into Loki — so do neither, and no Sentry. - log.warn("Rejected a request that violated a database integrity constraint"); + // Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which + // constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the + // offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault. + log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex)); return ResponseEntity.badRequest() .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint")); } + /** + * Returns the offending constraint's name from the cause chain, or {@code "unknown"}. + * Reads only the name (a non-sensitive schema identifier) — never the SQL or the values. + */ + private static String constraintNameOf(Throwable ex) { + for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) { + if (t instanceof org.hibernate.exception.ConstraintViolationException cve + && cve.getConstraintName() != null) { + return cve.getConstraintName(); + } + } + return "unknown"; + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { Sentry.captureException(ex); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java index 3a51993f..c75e9fae 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java @@ -72,9 +72,48 @@ class GlobalExceptionHandlerTest { .as("logs a WARN line") .anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN)); assertThat(appender.list) - .as("never logs the constraint name / SQL (would re-leak to Loki)") + .as("never logs the SQL statement / values (would re-leak to Loki)") .noneSatisfy(e -> { - assertThat(e.getFormattedMessage()).contains("chk_"); + assertThat(e.getFormattedMessage()).contains("could not execute statement"); }); } + + @Test + void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() { + // Debuggability (DevOps): the WARN must name *which* constraint fired so an + // unanticipated violation isn't a silent mystery — but it must carry the name only, + // never the SQL statement or the offending values that the SQLException message holds. + java.sql.SQLException sql = new java.sql.SQLException( + "ERROR: violates check constraint; could not execute statement; values (1917-01-10)"); + org.hibernate.exception.ConstraintViolationException cve = + new org.hibernate.exception.ConstraintViolationException( + "constraint violation", sql, "chk_meta_date_end_after_start"); + DataIntegrityViolationException ex = new DataIntegrityViolationException("wrapper", cve); + + Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + handlerLogger.addAppender(appender); + + try (MockedStatic sentryMock = mockStatic(Sentry.class)) { + ResponseEntity response = + handler.handleDataIntegrityViolation(ex); + + // Response stays generic and leak-free (CWE-209) regardless of what we log. + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody().message()) + .doesNotContain("chk_") + .doesNotContain("meta_date"); + sentryMock.verifyNoInteractions(); + } finally { + handlerLogger.detachAppender(appender); + } + + assertThat(appender.list) + .as("WARN names the constraint for debuggability") + .anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("chk_meta_date_end_after_start")); + assertThat(appender.list) + .as("but never the SQL statement or values") + .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains("could not execute statement")); + } } -- 2.49.1 From 1dd162f1be88990aa3b47d3d3fed8c5f4f218a6a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 11:03:28 +0200 Subject: [PATCH 8/8] test(document): prove the DB rejects end-before-start; assert persisted end (#678) 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 --- .../document/DocumentRepositoryTest.java | 22 +++++++++++++++++++ .../document/DocumentServiceTest.java | 1 + 2 files changed, 23 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java index a805925b..05a69895 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java @@ -38,7 +38,10 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import org.springframework.dao.DataIntegrityViolationException; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -635,6 +638,25 @@ class DocumentRepositoryTest { assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); } + @Test + void save_rejectsRange_whenEndBeforeStart_atDbLevel() { + // The app guard normally intercepts this, so the DB CHECK never fires in practice. + // Persisting directly proves chk_meta_date_end_after_start actually rejects end < start + // (H2 would not) — if the app guard ever regresses, a bad row still can't reach the table, + // and this is exactly the violation the GlobalExceptionHandler backstop turns into a 400. + Document doc = Document.builder() + .title("Verdrehte Spanne") + .originalFilename("verdreht.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(LocalDate.of(1917, 1, 11)) + .metaDatePrecision(DatePrecision.RANGE) + .metaDateEnd(LocalDate.of(1917, 1, 10)) + .build(); + + assertThatThrownBy(() -> documentRepository.saveAndFlush(doc)) + .isInstanceOf(DataIntegrityViolationException.class); + } + // ─── seeding helpers ───────────────────────────────────────────────────── private Document uploaded(String title) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 4c11bd30..0ed058a8 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -283,6 +283,7 @@ class DocumentServiceTest { documentService.updateDocument(id, rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null); + assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11)); verify(documentRepository, atLeastOnce()).save(any()); } -- 2.49.1