diff --git a/CLAUDE.md b/CLAUDE.md index 552aeefc..ae9127c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). ### Security / Permissions @@ -271,7 +271,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints). --- 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 ba8eac3b..4fb36a13 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -136,6 +136,10 @@ public enum ErrorCode { GESCHICHTE_TYPE_IMMUTABLE, /** A journey-item note exceeds the maximum length (2000 characters). 400 */ JOURNEY_NOTE_TOO_LONG, + /** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */ + GESCHICHTE_TITLE_TOO_LONG, + /** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */ + GESCHICHTE_INTRO_TOO_LONG, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 9df6c131..34f3ff9b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -50,6 +50,15 @@ public class GeschichteService { private static final int DEFAULT_LIMIT = 50; private static final int MAX_LIMIT = 200; + // Matches the geschichten.title VARCHAR(255) column (V58) — the service check + // turns what would be a DB-level 500 into a friendly 400. + static final int MAX_TITLE_LENGTH = 255; + // JOURNEY intros travel the verbatim (unsanitized) write path, so they get the + // same three-layer bound as journey notes: frontend maxlength, this check, and + // the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay + // unbounded on purpose. + static final int MAX_INTRO_LENGTH = 4000; + // ─── Read API ──────────────────────────────────────────────────────────── public long countPublished() { @@ -205,6 +214,10 @@ public class GeschichteService { throw DomainException.badRequest( ErrorCode.VALIDATION_ERROR, "Title is required"); } + if (title.trim().length() > MAX_TITLE_LENGTH) { + throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG, + "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters"); + } } /** @@ -214,7 +227,14 @@ public class GeschichteService { * corrupt content ("&" → "&") and re-encode on every editor round-trip. */ private String bodyForType(GeschichteType type, String body) { - return type == GeschichteType.JOURNEY ? body : sanitize(body); + if (type != GeschichteType.JOURNEY) { + return sanitize(body); + } + if (body != null && body.length() > MAX_INTRO_LENGTH) { + throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG, + "Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters"); + } + return body; } private String sanitize(String body) { diff --git a/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql b/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql new file mode 100644 index 00000000..3444a78f --- /dev/null +++ b/backend/src/main/resources/db/migration/V75__geschichten_journey_intro_length.sql @@ -0,0 +1,16 @@ +-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same +-- three-layer bound as journey notes: frontend maxlength, the +-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop. +-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose. +-- +-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the +-- DB layer; the service-level check exists to turn that 500 into a friendly 400. + +-- Defensive clamp first: intros written before this migration may exceed the +-- cap. No-op on a clean database. +UPDATE geschichten SET body = left(body, 4000) + WHERE type = 'JOURNEY' AND length(body) > 4000; + +ALTER TABLE geschichten + ADD CONSTRAINT chk_geschichte_journey_intro_length + CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java new file mode 100644 index 00000000..ff0baa60 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteConstraintsTest.java @@ -0,0 +1,66 @@ +package org.raddatz.familienarchiv.geschichte; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at + * class level (see JourneyItemConstraintsTest for the rationale). + * + * The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on + * the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class GeschichteConstraintsTest { + + @MockitoBean + S3Client s3Client; + + @Autowired JdbcTemplate jdbcTemplate; + + private UUID insertGeschichte(String type, String body) { + UUID id = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) " + + "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())", + id, "Constraints-Test", body, type); + return id; + } + + @Test + void journey_intro_check_rejects_4001_chars() { + assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void journey_intro_check_accepts_exactly_4000_chars() { + UUID id = insertGeschichte("JOURNEY", "x".repeat(4000)); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id); + assertThat(count).isEqualTo(1); + } + + @Test + void story_bodies_are_not_constrained_by_the_intro_check() { + UUID id = insertGeschichte("STORY", "

" + "x".repeat(4001) + "

"); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id); + assertThat(count).isEqualTo(1); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 37e00712..73693c27 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -477,6 +477,114 @@ class GeschichteServiceTest { assertThat(saved.body()).doesNotContain("