diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 11d47992..b8b59cdc 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -161,3 +161,147 @@ jobs: # without first re-evaluating ADR-011. if: always() run: rm -f .env.staging + + npm-audit: + # Independent parallel job — a deploy failure cannot mask the audit signal + # and a clean audit cannot hide a broken deploy. Intentionally no `needs:`. + # + # Scans dev deps too (no --omit=dev), which is deliberately broader than the + # PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader + # result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild, + # Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate. + # + # Required Gitea secrets: + # NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token + # means a leak via logs/process-args cannot push + # branches, open PRs, or read repo contents (ADR-041). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assert jq is available + run: which jq || sudo apt-get install -y jq + + - name: Run npm audit and file tracking issue on findings + # Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs. + env: + NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }} + run: | + MARKER="Nightly npm audit: high-severity advisory" + GITEA_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}" + + # --- Self-test (mirrors ci.yml §Assert pattern) --- + # Tests the exact jq test() call used in the dedupe step, before any + # API call, so a broken matcher fails loudly early rather than silently + # opening duplicate issues. Proves the regex only — create-vs-update + # decision is exercised by the workflow_dispatch AC. + echo "{\"title\": \"${MARKER}\"}" \ + | jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \ + || { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; } + echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \ + | jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \ + || { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; } + echo "Self-test passed." + + # --- Run audit --- + # No npm ci — audit reads only the lockfile (no network, no install). + set +e + (cd frontend && npm audit --audit-level=high --json > /tmp/audit.json) + AUDIT_EXIT=$? + set -e + + if [ "$AUDIT_EXIT" -ne 0 ]; then + # --- Build issue body with jq (never string-concat advisory text) --- + # Advisory overview/title text is registry-controlled; string-concat + # would be an injection/escaping vector into the API body. Truncate + # raw excerpt to 500 chars so a pathological overview can't produce + # a multi-MB PATCH body. + ISSUE_BODY=$(jq -r \ + --arg run_url "$RUN_URL" \ + ' + (.vulnerabilities // {}) as $vulns | + ($vulns | to_entries | + map(select(.value.severity == "high" or .value.severity == "critical")) | + map("- **" + .key + "** (" + .value.severity + ")") | + if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list | + "## npm audit: high/critical advisories\n\n" + $pkg_list + + "\n\n**Run:** " + $run_url + + "\n\n
Raw audit excerpt (first 500 chars)\n\n```\n" + + (tostring | .[0:500]) + + "\n```\n\n
" + ' /tmp/audit.json) + + # --- Dedupe: fetch open security issues, match by title marker --- + # Renovate vuln PRs also carry the "security" label, so >1 open + # "security" issue WILL occur. Title-match (not just label) ensures + # we deduplicate only our own tracking issue. + OPEN_ISSUES=$(curl -sf \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50") + + MATCHED=$(echo "$OPEN_ISSUES" | jq \ + --arg m "$MARKER" \ + '[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)') + MATCH_COUNT=$(echo "$MATCHED" | jq 'length') + + if [ "$MATCH_COUNT" -gt 0 ]; then + # Patch the oldest matched issue (append run URL to body). + ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number') + EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body') + NEW_BODY=$(jq -n \ + --arg existing "$EXISTING_BODY" \ + --arg run_url "$RUN_URL" \ + '$existing + "\n\n---\n\nUpdated by run: " + $run_url') + PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}') + curl -sf -X PATCH \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null + echo "Updated tracking issue #${ISSUE_NUMBER}" + else + # Closed prior issue that recurs → new issue (not reopened). + # A re-opened issue would obscure when the advisory was re-discovered. + PAYLOAD=$(jq -n \ + --arg title "$MARKER" \ + --arg body "$ISSUE_BODY" \ + '{"title": $title, "body": $body}') + CREATED=$(curl -sf -X POST \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues") + NEW_NUMBER=$(echo "$CREATED" | jq -r '.number') + echo "Opened new tracking issue #${NEW_NUMBER}" + + # Labels are ignored on issue create in Gitea — add in a follow-up call. + LABEL_IDS=$(curl -sf \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + "${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \ + | jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]') + curl -sf -X POST \ + -H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"labels\": $LABEL_IDS}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null + fi + + exit "$AUDIT_EXIT" + + else + # --- Heartbeat: proves the job ran and found nothing --- + # "No issue created" is only meaningful evidence when paired with a + # visible positive signal. Without this, a never-ran job is + # indistinguishable from a clean run. + # + # $GITHUB_STEP_SUMMARY availability is unproven on this runner + # (act_runner populates it, but this is the first run to verify it). + # Guard before use so an unset variable does not fail the clean-path. + MSG="✅ npm audit clean $(date -u)" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "$MSG" >> "$GITHUB_STEP_SUMMARY" + fi + echo "$MSG" + fi diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml new file mode 100644 index 00000000..da9e454d --- /dev/null +++ b/.gitea/workflows/renovate.yml @@ -0,0 +1,44 @@ +name: Renovate + +# Runs Renovate daily to surface newly-published advisories via OSV.dev +# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch +# schedule (see renovate.json §schedule). Security/vulnerability PRs are +# raised immediately regardless of the weekly schedule window. +# +# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md): +# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues +# Belongs to a dedicated bot account. Branch protection +# on main must forbid this bot pushing directly. +# +# Platform config is injected via env vars below; the renovate.json in the +# repo root carries only dependency rules (no platform/endpoint/repos). +# +# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd +# corresponds to release v46.1.15. Update by bumping both the digest and the +# renovate-version when Renovate publishes a new release. Renovate itself +# will open a PR to bump this digest once it runs. + +on: + schedule: + - cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day + workflow_dispatch: + +jobs: + renovate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Renovate + # Pinned by digest — this action holds contents+pull_request+issues + # scopes; an unpinned tag is a supply-chain risk (see ADR-041). + uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15 + with: + configurationFile: renovate.json + token: ${{ secrets.RENOVATE_TOKEN }} + renovate-version: "46.1.15" + env: + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: https://git.raddatz.cloud + RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]' + LOG_LEVEL: info diff --git a/CLAUDE.md b/CLAUDE.md index b424a47f..e8932627 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,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`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (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); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). ### Security / Permissions @@ -277,7 +277,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`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (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); `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG` (timeline event CRUD), plus a generic `CONFLICT` (409 optimistic-lock backstop). --- 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 5c6b6d95..bea7cf66 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -155,6 +155,14 @@ public enum ErrorCode { /** The merge target is a descendant of the source tag. 400 */ TAG_MERGE_INVALID_TARGET, + // --- Timeline (Zeitstrahl) --- + /** A timeline event with the given ID does not exist. 404 */ + TIMELINE_EVENT_NOT_FOUND, + /** Optimistic-locking conflict — the timeline event was modified by another curator. 409 */ + TIMELINE_EVENT_CONFLICT, + /** A timeline event title exceeds the maximum length (255 characters — the DB column bound). 400 */ + TIMELINE_TITLE_TOO_LONG, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, @@ -162,6 +170,8 @@ public enum ErrorCode { BATCH_TOO_LARGE, /** Bulk edit request exceeds the per-request document ID cap. 400 */ BULK_EDIT_TOO_MANY_IDS, + /** A concurrent modification was detected (generic optimistic-lock backstop). 409 */ + CONFLICT, /** An unexpected server-side error occurred. 500 */ INTERNAL_ERROR, } 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 c56cc576..b6484c86 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -104,6 +104,30 @@ public class GlobalExceptionHandler { return "unknown"; } + /** + * Generic backstop for optimistic-locking conflicts that escape a service-level catch. A + * conflict is a 409, not a system fault — so, like {@link #handleDataIntegrityViolation}, it + * must NOT fire Sentry and must NOT leak Hibernate internals (CWE-209): the response carries + * only the generic {@link ErrorCode#CONFLICT} code and a generic message — no entity id, no + * version, no persistent-class name. + * + *

Deliberately code-GENERIC: do NOT {@code switch} on {@code getPersistentClassName()} to map + * back to a per-entity code. Unlike {@link #handleDataIntegrityViolation}, which branches on + * stable schema constraint NAMES, persistent-class names are not a contract. The precise, + * code-carrying path is the service catch (e.g. {@code TIMELINE_EVENT_CONFLICT}); this is only + * the net that keeps any current or future write path from regressing to a 500. + */ + @ExceptionHandler(org.springframework.orm.ObjectOptimisticLockingFailureException.class) + public ResponseEntity handleOptimisticLock( + org.springframework.orm.ObjectOptimisticLockingFailureException ex) { + // Log the persistent-class name ONLY (schema metadata, safe for Loki). Never `ex` / + // ex.getMessage(): those embed the entity id + version (CWE-209). No Sentry: it's a 409. + log.warn("Rejected a write that lost an optimistic-lock race on: {}", ex.getPersistentClassName()); + return ResponseEntity.status(409) + .body(new ErrorResponse(ErrorCode.CONFLICT, + "The resource was modified concurrently. Please reload and try again.")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { Sentry.captureException(ex); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java new file mode 100644 index 00000000..e3c62e74 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java @@ -0,0 +1,71 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.security.SecurityUtils; +import org.raddatz.familienarchiv.user.UserService; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/timeline/events") +@RequiredArgsConstructor +public class TimelineEventController { + + private final TimelineEventService timelineEventService; + private final UserService userService; + + /** + * No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()} + * rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not + * "fix" the missing annotation. + */ + @GetMapping("/{id}") + public TimelineEventView getEvent(@PathVariable UUID id) { + return timelineEventService.getEvent(id); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.WRITE_ALL) + public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) { + return timelineEventService.create(request, requireUserId(authentication)); + } + + @PutMapping("/{id}") + @RequirePermission(Permission.WRITE_ALL) + public TimelineEventView update( + @PathVariable UUID id, + @Valid @RequestBody TimelineEventRequest request, + Authentication authentication) { + return timelineEventService.update(id, request, requireUserId(authentication)); + } + + @DeleteMapping("/{id}") + @RequirePermission(Permission.WRITE_ALL) + public ResponseEntity delete(@PathVariable UUID id) { + timelineEventService.delete(id); + return ResponseEntity.noContent().build(); + } + + private UUID requireUserId(Authentication authentication) { + return SecurityUtils.requireUserId(authentication, userService); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java new file mode 100644 index 00000000..dd25b3e9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventRequest.java @@ -0,0 +1,40 @@ +package org.raddatz.familienarchiv.timeline; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +/** + * Flat input DTO for creating/updating a {@link TimelineEvent}. Bean Validation fires at the + * controller boundary (via {@code @Valid}) and produces a 400 {@code VALIDATION_ERROR} for the + * presence/size constraints below; cross-field rules (the RANGE invariant), date normalization, + * id dedupe, and the title-length structured-error guard live in {@code TimelineEventService}. + * + *

{@code createdBy}/{@code updatedBy} are intentionally absent. Authorship is + * server-populated from the session principal only — accepting it from the body would be an + * authorship-forgery / mass-assignment vector (CWE-639; see ADR-040 §7). + * + * @param version optional optimistic-lock concurrency token (the {@code @Version} the client last + * saw), applied on update only. This is a concurrency token, not + * an authorship field, so it is deliberately exempt from the §7 server-only audit rule. + * Null on update means "no concurrency check" (last-write-wins). No range validation — + * a stale/negative value is simply a mismatch the lock rejects at flush; the lock, not + * a validator, is the control. + */ +public record TimelineEventRequest( + @NotBlank @Size(max = 255) String title, + @NotNull EventType type, + @NotNull LocalDate eventDate, + DatePrecision precision, + LocalDate eventDateEnd, + @Size(max = 5000) String description, + Long version, + @Size(max = 50) List personIds, + @Size(max = 50) List documentIds +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java new file mode 100644 index 00000000..e7c0d892 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventService.java @@ -0,0 +1,247 @@ +package org.raddatz.familienarchiv.timeline; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; +import org.raddatz.familienarchiv.timeline.TimelineEventView.DocumentRef; +import org.raddatz.familienarchiv.timeline.TimelineEventView.PersonView; + +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Curator CRUD for {@link TimelineEvent}. Persons and documents are resolved through their own + * services (never their repositories). All four body-returning operations return a + * {@link TimelineEventView} assembled in-transaction — the entity is never serialized (ADR-040 §2). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TimelineEventService { + + private static final int MAX_TITLE_LENGTH = 255; + + private final TimelineEventRepository events; + private final PersonService personService; + private final DocumentService documentService; + + @Transactional + public TimelineEventView create(TimelineEventRequest request, UUID actorId) { + DatePrecision precision = effectivePrecision(request); + validateRangeInvariant(request, precision); + validateTitleLength(request); + + TimelineEvent event = TimelineEvent.builder() + .title(request.title()) + .type(request.type()) + .eventDate(normalizeEventDate(request.eventDate(), precision)) + .precision(precision) + .eventDateEnd(request.eventDateEnd()) + .description(request.description()) + .persons(resolvePersons(request.personIds())) + .documents(resolveDocuments(request.documentIds())) + .createdBy(actorId) + .updatedBy(actorId) + .build(); + + return toView(events.saveAndFlush(event)); + } + + @Transactional + public TimelineEventView update(UUID id, TimelineEventRequest request, UUID actorId) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + requireVersionMatch(request, event); + DatePrecision precision = effectivePrecision(request); + validateRangeInvariant(request, precision); + validateTitleLength(request); + applyUpdate(event, request, precision, actorId); + + // saveAndFlush (not save) so the versioned UPDATE …WHERE version=? fires HERE, inside the + // try — a bare save() flushes at commit, after this method returns, so the exception would + // escape the catch and surface as a 500. Catch the Spring-translated type, not JPA's. + try { + return toView(events.saveAndFlush(event)); + } catch (ObjectOptimisticLockingFailureException ex) { + throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT, + "Timeline event was modified concurrently: " + id); + } + } + + @Transactional + public void delete(UUID id) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + events.delete(event); + } + + /** + * View-assembly read. {@code @Transactional(readOnly = true)} is load-bearing, not optional: + * the LAZY {@code persons}/{@code documents} collections are traversed during {@link #toView} + * assembly, and under {@code open-in-view: false} a closed session there is a + * {@code LazyInitializationException} (ADR-022 / {@code getDocumentDetail} precedent). + */ + @Transactional(readOnly = true) + public TimelineEventView getEvent(UUID id) { + TimelineEvent event = events.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, + "Timeline event not found: " + id)); + return toView(event); + } + + // --- update mechanics: mutate the managed entity, never reassign collections --- + + private void applyUpdate(TimelineEvent event, TimelineEventRequest request, DatePrecision precision, UUID actorId) { + event.setTitle(request.title()); + event.setType(request.type()); + event.setEventDate(normalizeEventDate(request.eventDate(), precision)); + event.setPrecision(precision); + event.setEventDateEnd(request.eventDateEnd()); + event.setDescription(request.description()); + replaceLinks(event, request); + event.setUpdatedBy(actorId); // preserve createdBy — only the editor changes + } + + /** + * Compares the client's concurrency token against the freshly-loaded version (the Q1 + * "last-seen version" token). A mismatch means the client edited stale data → 409. + * + *

This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before + * flush. Setting {@code @Version} on a managed entity is silently ignored by Hibernate + * for the optimistic check: it uses its own loaded-version snapshot for the + * {@code UPDATE … WHERE version=?} clause, so a stale token never reaches the DB. The native + * {@code @Version} increment still happens on every save, and the {@code saveAndFlush}+catch + * below remains the backstop for two transactions flushing concurrently; this guard is what + * catches the human-timescale "B submitted a form based on a version A already superseded" case. + * A null token means no check (last-write-wins) until #9 always sends it. + */ + private void requireVersionMatch(TimelineEventRequest request, TimelineEvent event) { + if (request.version() != null && !request.version().equals(event.getVersion())) { + throw DomainException.conflict(ErrorCode.TIMELINE_EVENT_CONFLICT, + "Timeline event was modified concurrently: " + event.getId()); + } + } + + /** + * Replaces (set semantics) the link collections. Mutates the existing managed collections — + * Hibernate does not track a reassigned reference, and a fresh {@code Set} risks orphan join + * rows against the {@code ON DELETE CASCADE} join tables. A null or empty list clears all links. + */ + private void replaceLinks(TimelineEvent event, TimelineEventRequest request) { + event.getPersons().clear(); + event.getPersons().addAll(resolvePersons(request.personIds())); + event.getDocuments().clear(); + event.getDocuments().addAll(resolveDocuments(request.documentIds())); + } + + // --- validation / normalization --- + + /** + * Mirrors the DB biconditional CHECK chk_timeline_event_range — both presence directions — and + * additionally enforces date ordering, which the DB CHECK does NOT: {@code eventDateEnd} may + * equal but never precede {@code eventDate}. Without this guard a reversed range (end before + * start) persists silently and renders as a negative span. Equal dates are a valid one-day + * closed range. + */ + private void validateRangeInvariant(TimelineEventRequest request, DatePrecision precision) { + boolean isRange = precision == DatePrecision.RANGE; + if (request.eventDateEnd() != null && !isRange) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "eventDateEnd is only valid when precision is RANGE"); + } + if (isRange && request.eventDateEnd() == null) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "A RANGE event requires a non-null eventDateEnd"); + } + if (isRange && request.eventDateEnd().isBefore(request.eventDate())) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "eventDateEnd must not precede eventDate"); + } + } + + /** + * Load-bearing only for non-HTTP callers: the DTO {@code @Size(max = 255)} already covers HTTP + * callers, but a non-HTTP caller could otherwise push an over-long title to the VARCHAR(255) + * column and get a raw {@code DataIntegrityViolationException} → 500. Do not delete as + * "duplicate validation". + */ + private void validateTitleLength(TimelineEventRequest request) { + if (request.title() != null && request.title().length() > MAX_TITLE_LENGTH) { + throw DomainException.badRequest(ErrorCode.TIMELINE_TITLE_TOO_LONG, + "Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters"); + } + } + + private DatePrecision effectivePrecision(TimelineEventRequest request) { + return request.precision() != null ? request.precision() : DatePrecision.YEAR; + } + + private LocalDate normalizeEventDate(LocalDate eventDate, DatePrecision precision) { + return precision == DatePrecision.YEAR ? LocalDate.of(eventDate.getYear(), 1, 1) : eventDate; + } + + // --- link resolution (fail-closed, dedupe-first) --- + + private Set resolvePersons(List ids) { + if (ids == null || ids.isEmpty()) { + return new HashSet<>(); + } + // Dedupe FIRST: [idA, idA] is one link, not a 404. findAllById dedupes too, so compare the + // resolved size against the DISTINCT input count — a raw ids.size() compare reports a spurious + // mismatch. + Set distinct = new LinkedHashSet<>(ids); + List resolved = personService.getAllById(new ArrayList<>(distinct)); + if (resolved.size() != distinct.size()) { + throw DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"); + } + return new HashSet<>(resolved); + } + + private Set resolveDocuments(List ids) { + if (ids == null || ids.isEmpty()) { + return new HashSet<>(); + } + // Per-id loop on purpose: DocumentService has no batch fetch, and per-id gives free + // DOCUMENT_NOT_FOUND 404s. getDocumentById is @Transactional(readOnly = true) and joins this + // write tx via Spring's default REQUIRED propagation — do NOT "optimize" into a phantom batch. + Set resolved = new HashSet<>(); + for (UUID documentId : new LinkedHashSet<>(ids)) { + resolved.add(documentService.getDocumentById(documentId)); + } + return resolved; + } + + // --- view assembly (explicit allow-list; never the raw entity) --- + + private TimelineEventView toView(TimelineEvent event) { + List persons = event.getPersons().stream() + .map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName())) + .toList(); + List documents = event.getDocuments().stream() + .map(d -> new DocumentRef(d.getId(), d.getTitle(), d.getDocumentDate())) + .toList(); + return new TimelineEventView( + event.getId(), event.getTitle(), event.getType(), event.getEventDate(), + event.getPrecision(), event.getEventDateEnd(), event.getDescription(), event.getVersion(), + event.getCreatedBy(), event.getCreatedAt(), event.getUpdatedBy(), event.getUpdatedAt(), + persons, documents); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java new file mode 100644 index 00000000..ac67af11 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventView.java @@ -0,0 +1,59 @@ +package org.raddatz.familienarchiv.timeline; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.raddatz.familienarchiv.document.DatePrecision; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Response view for the timeline event endpoints — returned by GET, POST, and PUT alike. + * Assembled inside the service transaction (after {@code saveAndFlush} on the write paths, so + * {@code version} is non-null) from the managed entity's already-loaded collections. The raw + * {@link TimelineEvent} is never serialized: its LAZY {@code persons}/{@code documents} + * collections under {@code open-in-view: false} would otherwise 500 (ADR-036/ADR-040 §2), and + * splatting the entities would leak curator-internal fields ({@code Person.notes}, + * {@code provisional}, transcription data) to every READ_ALL reader. The explicit field + * allow-list below is that guarantee. + */ +public record TimelineEventView( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDate eventDate, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision, + LocalDate eventDateEnd, + String description, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long version, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID createdBy, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID updatedBy, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List persons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List documents +) { + /** Summarised person — exposes only id, firstName, and lastName. Mirrors GeschichteView.PersonView. */ + public record PersonView( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + String firstName, + String lastName + ) {} + + /** + * Summarised linked document — id, title, and the eager {@code documentDate} only (no lazy + * sender/receiver hop, no person-name leak through the document side). + * + *

timeline-local by design; do not promote to {@code document/} — see #775 R7. Reusing + * {@code geschichte.journeyitem.DocumentSummary} would force a cross-domain import of a + * package-private mapper plus duplicated name-assembly logic; a 3-field local record is the + * lower-coupling choice. + */ + public record DocumentRef( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title, + LocalDate documentDate + ) {} +} 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 324c17a1..ef3028ba 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java @@ -14,6 +14,9 @@ import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mockStatic; @@ -103,6 +106,49 @@ class GlobalExceptionHandlerTest { }); } + @Test + void handleOptimisticLock_returns409_genericConflict_noSentry_noLeak() { + // CWE-209 regression: an optimistic-lock failure escaping a service catch must become a + // generic 409, never a 500 + Sentry + Hibernate internals. The generic CONFLICT code keeps + // it entity-agnostic — NOT TIMELINE_EVENT_CONFLICT, or every future entity's conflict is + // mislabeled a timeline one. + UUID entityId = UUID.randomUUID(); + ObjectOptimisticLockingFailureException ex = + new ObjectOptimisticLockingFailureException("com.example.SomeEntity", entityId); + + 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.handleOptimisticLock(ex); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ErrorCode.CONFLICT); + // Body echoes no persistent-class name, no entity id, no version (enumeration aids). + assertThat(response.getBody().message()) + .doesNotContain("SomeEntity") + .doesNotContain(entityId.toString()); + + // A conflict is not a system fault — no fabricated Sentry alert. + sentryMock.verifyNoInteractions(); + } finally { + handlerLogger.detachAppender(appender); + } + + assertThat(appender.list) + .as("WARN names the persistent class for debuggability") + .anySatisfy(e -> { + assertThat(e.getLevel()).isEqualTo(Level.WARN); + assertThat(e.getFormattedMessage()).contains("com.example.SomeEntity"); + }); + assertThat(appender.list) + .as("but never the entity id (would leak via getMessage())") + .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains(entityId.toString())); + } + @Test void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() { // Debuggability (DevOps): the WARN must name *which* constraint fired so an diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java new file mode 100644 index 00000000..3c6442db --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java @@ -0,0 +1,273 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.security.SecurityConfig; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.CustomUserDetailsService; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TimelineEventController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class TimelineEventControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean TimelineEventService timelineEventService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final String VALID_JSON = + "{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"; + + /** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */ + @BeforeEach + void resolveDefaultPrincipal() { + when(userService.findByEmail("user")) + .thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build()); + } + + private TimelineEventView sampleView() { + return new TimelineEventView( + UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1), + org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L, + UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(), + List.of(), List.of()); + } + + // ─── POST /api/timeline/events ─────────────────────────────────────────── + + @Test + void create_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void create_returns403_whenOnlyReadAll() throws Exception { + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns201_andViewBody_whenWriteAll() throws Exception { + when(timelineEventService.create(any(), any())).thenReturn(sampleView()); + + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.title").value("Hochzeit")) + .andExpect(jsonPath("$.version").value(0)); + } + + // ─── PUT /api/timeline/events/{id} ─────────────────────────────────────── + + @Test + void update_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void update_returns403_whenOnlyReadAll() throws Exception { + mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void update_returns200_andViewBody_whenWriteAll() throws Exception { + when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView()); + + mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON).content(VALID_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Hochzeit")); + } + + // ─── DELETE /api/timeline/events/{id} ──────────────────────────────────── + + @Test + void delete_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void delete_returns403_whenOnlyReadAll() throws Exception { + mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void delete_returns204_whenWriteAll() throws Exception { + mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf())) + .andExpect(status().isNoContent()); + } + + // ─── GET /api/timeline/events/{id} ─────────────────────────────────────── + + @Test + void getEvent_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getEvent_returns200_whenAuthenticated() throws Exception { + when(timelineEventService.getEvent(any())).thenReturn(sampleView()); + + mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Hochzeit")); + } + + @Test + @WithMockUser + void getEvent_returns404_whenMissing() throws Exception { + when(timelineEventService.getEvent(any())) + .thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found")); + + mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + // ─── service-thrown link errors map to status ──────────────────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns404_whenServiceThrowsPersonNotFound() throws Exception { + when(timelineEventService.create(any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found")); + + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\"" + + UUID.randomUUID() + "\"]}")) + .andExpect(status().isNotFound()); + } + + // ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ─────────────── + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception { + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception { + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception { + String description = "x".repeat(5001); + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\"" + + description + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception { + String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(",")); + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[" + + ids + "]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } + + // ─── actorId is the resolved session principal, not a body field (both write paths) ── + + @Test + @WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL") + void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception { + UUID principalId = UUID.randomUUID(); + when(userService.findByEmail("curator@example.com")) + .thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build()); + when(timelineEventService.create(any(), any())).thenReturn(sampleView()); + + // body carries a forged createdBy — it must be ignored, actor comes from the principal + mockMvc.perform(post("/api/timeline/events").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\"" + + UUID.randomUUID() + "\"}")) + .andExpect(status().isCreated()); + + verify(timelineEventService).create(any(), eq(principalId)); + } + + @Test + @WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL") + void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception { + UUID principalId = UUID.randomUUID(); + UUID eventId = UUID.randomUUID(); + when(userService.findByEmail("curator@example.com")) + .thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build()); + when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView()); + + mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\"" + + UUID.randomUUID() + "\"}")) + .andExpect(status().isOk()); + + verify(timelineEventService).update(eq(eventId), any(), eq(principalId)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java new file mode 100644 index 00000000..1ee456cc --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java @@ -0,0 +1,109 @@ +package org.raddatz.familienarchiv.timeline; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the + * in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)} + * LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version} + * optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres + * (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class TimelineEventServiceIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired TimelineEventService service; + @Autowired PersonRepository personRepository; + @Autowired DocumentRepository documentRepository; + @PersistenceContext EntityManager em; + + // Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean. + // findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize. + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + private TimelineEventRequest request(String title, Long version, List personIds, List documentIds) { + return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, version, personIds, documentIds); + } + + @Test + void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception { + Person anna = personRepository.save(Person.builder() + .firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build()); + Document letter = documentRepository.save(Document.builder() + .title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build()); + UUID eventId = service.create( + request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id(); + em.flush(); + em.clear(); + + // Fresh read — NOT the create return value (that view was assembled while the entity was + // managed). Only a separate read after the clear proves the readOnly LazyInit guard. + TimelineEventView fresh = service.getEvent(eventId); + + assertThat(fresh.persons()).singleElement().satisfies(p -> { + assertThat(p.firstName()).isEqualTo("Anna"); + assertThat(p.lastName()).isEqualTo("Müller"); + }); + assertThat(fresh.documents()).singleElement().satisfies(d -> + assertThat(d.title()).isEqualTo("Brief an Anna")); + + // Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip + // past a field-level check. + String json = objectMapper.writeValueAsString(fresh); + assertThat(json) + .doesNotContain("GEHEIM-NOTIZ") + .doesNotContain("notes") + .doesNotContain("provisional") + .doesNotContain("password"); + } + + @Test + void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() { + UUID editorA = UUID.randomUUID(); + UUID editorB = UUID.randomUUID(); + UUID eventId = service.create(request("Original", null, null, null), editorA).id(); + em.flush(); + em.clear(); + + // Editor A saves with the version they last saw (0) → succeeds, version advances to 1. + service.update(eventId, request("Edit A", 0L, null, null), editorA); + em.flush(); + em.clear(); + + // Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409. + assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java new file mode 100644 index 00000000..658a198f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceTest.java @@ -0,0 +1,453 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonService; + +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.returnsFirstArg; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TimelineEventServiceTest { + + @Mock TimelineEventRepository events; + @Mock PersonService personService; + @Mock DocumentService documentService; + @InjectMocks TimelineEventService service; + + private final UUID actor = UUID.randomUUID(); + private final UUID secondEditor = UUID.randomUUID(); + + /** Mirrors #774's makeEvent defaults so NOT NULL createdBy/updatedBy aren't tripped for the wrong reason. */ + private TimelineEventRequest baseRequest() { + return new TimelineEventRequest( + "Hochzeit von Anna und Otto", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, null); + } + + private Person makePerson(UUID id) { + return Person.builder().id(id).firstName("Anna").lastName("Müller").build(); + } + + private Document makeDocument(UUID id) { + return Document.builder().id(id).title("Brief an Anna").documentDate(LocalDate.of(1914, 6, 1)).build(); + } + + /** A managed, persisted event for update/delete/get paths — version 5, distinct creator. */ + private TimelineEvent existingEvent(UUID id, UUID creator) { + return TimelineEvent.builder() + .id(id).title("Original").type(EventType.PERSONAL).eventDate(LocalDate.of(1914, 1, 1)) + .precision(DatePrecision.YEAR).createdBy(creator).updatedBy(creator).version(5L) + .build(); + } + + /** Stubs saveAndFlush to mimic Hibernate setting version=0 on insert; returns the same managed entity. */ + private void stubFlushSetsVersion() { + when(events.saveAndFlush(any())).thenAnswer(inv -> { + TimelineEvent e = inv.getArgument(0); + if (e.getVersion() == null) e.setVersion(0L); + return e; + }); + } + + private TimelineEvent captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(TimelineEvent.class); + verify(events).saveAndFlush(captor.capture()); + return captor.getValue(); + } + + // --- create --- + + @Test + void create_persists_and_sets_createdBy_and_updatedBy_from_actorId() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + TimelineEvent persisted = captureSaved(); + assertThat(persisted.getCreatedBy()).isEqualTo(actor); + assertThat(persisted.getUpdatedBy()).isEqualTo(actor); + assertThat(view.title()).isEqualTo("Hochzeit von Anna und Otto"); + assertThat(view.version()).isEqualTo(0L); + } + + @Test + void create_defaults_precision_to_YEAR_when_omitted() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + assertThat(view.precision()).isEqualTo(DatePrecision.YEAR); + } + + @Test + void create_normalizes_eventDate_to_first_of_january_when_precision_is_YEAR() { + stubFlushSetsVersion(); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + DatePrecision.YEAR, null, null, null, null, null); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.eventDate()).isEqualTo(LocalDate.of(1914, 1, 1)); + } + + @Test + void create_with_null_link_lists_yields_empty_collections_no_npe() { + stubFlushSetsVersion(); + + TimelineEventView view = service.create(baseRequest(), actor); + + assertThat(view.persons()).isEmpty(); + assertThat(view.documents()).isEmpty(); + } + + // --- RANGE invariant (both directions are separate tests; each asserts saveAndFlush never called) --- + + @Test + void create_rejects_eventDateEnd_when_precision_is_not_RANGE() { + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1), + DatePrecision.YEAR, LocalDate.of(1918, 1, 1), null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_rejects_RANGE_with_null_eventDateEnd() { + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1914, 1, 1), + DatePrecision.RANGE, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_rejects_RANGE_with_eventDateEnd_before_eventDate() { + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11), + DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() { + stubFlushSetsVersion(); + LocalDate sameDay = LocalDate.of(1914, 7, 28); + TimelineEventRequest request = new TimelineEventRequest( + "Eintägiges Ereignis", EventType.HISTORICAL, sameDay, + DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range + + TimelineEventView view = service.create(request, actor); + + assertThat(view.eventDate()).isEqualTo(sameDay); + assertThat(view.eventDateEnd()).isEqualTo(sameDay); + } + + // --- title-length service guard --- + + @Test + void create_rejects_title_longer_than_255_with_TIMELINE_TITLE_TOO_LONG() { + String overLong = "x".repeat(256); + TimelineEventRequest request = new TimelineEventRequest( + overLong, EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_TITLE_TOO_LONG); + verify(events, never()).saveAndFlush(any()); + } + + // --- link resolution (fail-closed, dedupe-first) --- + + @Test + void create_resolves_persons_and_documents() { + stubFlushSetsVersion(); + UUID personId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId))); + when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(personId), List.of(docId)); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.persons()).singleElement().satisfies(p -> assertThat(p.id()).isEqualTo(personId)); + assertThat(view.documents()).singleElement().satisfies(d -> assertThat(d.id()).isEqualTo(docId)); + } + + @Test + void create_with_duplicate_personIds_resolves_to_single_link_not_404() { + stubFlushSetsVersion(); + UUID personId = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(personId))); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(personId, personId), null); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.persons()).hasSize(1); + } + + @Test + void create_with_duplicate_documentIds_resolves_to_single_link_not_404() { + stubFlushSetsVersion(); + UUID docId = UUID.randomUUID(); + when(documentService.getDocumentById(docId)).thenReturn(makeDocument(docId)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, List.of(docId, docId)); + + TimelineEventView view = service.create(request, actor); + + assertThat(view.documents()).hasSize(1); + } + + @Test + void create_rejects_unknown_personId_with_PERSON_NOT_FOUND_without_saving() { + UUID known = UUID.randomUUID(); + UUID unknown = UUID.randomUUID(); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(known))); // one missing + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(known, unknown), null); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.PERSON_NOT_FOUND); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void create_rejects_unknown_documentId_with_DOCUMENT_NOT_FOUND_without_saving() { + UUID unknown = UUID.randomUUID(); + when(documentService.getDocumentById(unknown)) + .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + unknown)); + TimelineEventRequest request = new TimelineEventRequest( + "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, null, List.of(unknown)); + + assertThatThrownBy(() -> service.create(request, actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND); + verify(events, never()).saveAndFlush(any()); + } + + // --- update --- + + @Test + void update_replaces_links_preserves_createdBy_and_advances_updatedBy_to_second_editor() { + UUID id = UUID.randomUUID(); + UUID creator = UUID.randomUUID(); + UUID newPersonId = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, creator); + existing.getPersons().add(makePerson(UUID.randomUUID())); // pre-existing link to be replaced + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + when(personService.getAllById(anyList())).thenReturn(List.of(makePerson(newPersonId))); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(newPersonId), null); + + service.update(id, request, secondEditor); + + assertThat(existing.getCreatedBy()).isEqualTo(creator); + assertThat(existing.getUpdatedBy()).isEqualTo(secondEditor); + assertThat(existing.getPersons()).singleElement() + .satisfies(p -> assertThat(p.getId()).isEqualTo(newPersonId)); + } + + @Test + void update_with_empty_link_lists_clears_all_links() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + existing.getPersons().add(makePerson(UUID.randomUUID())); + existing.getDocuments().add(makeDocument(UUID.randomUUID())); + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, null, List.of(), List.of()); + + service.update(id, request, secondEditor); + + assertThat(existing.getPersons()).isEmpty(); + assertThat(existing.getDocuments()).isEmpty(); + } + + @Test + void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + TimelineEventRequest request = new TimelineEventRequest( + "Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11), + DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start + + assertThatThrownBy(() -> service.update(id, request, secondEditor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.update(id, baseRequest(), actor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } + + // --- version / optimistic lock --- + + // Note: the lock control is an explicit base-version compare (requireVersionMatch), NOT + // event.setVersion(clientVersion) — Hibernate silently ignores a manually-set @Version on a + // managed entity (proven by TimelineEventServiceIntegrationTest). The saveAndFlush+catch below + // is retained as the native backstop for two transactions flushing concurrently. + + @Test + void update_with_null_version_skips_the_check_and_saves_last_write_wins() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5 + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + + service.update(id, baseRequest(), secondEditor); // baseRequest has null version + + verify(events).saveAndFlush(existing); // no conflict despite an unknown client base + } + + @Test + void update_with_in_sync_version_succeeds_and_saves() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5 + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())).thenAnswer(returnsFirstArg()); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, 5L, null, null); // matches the loaded version + + TimelineEventView view = service.update(id, request, secondEditor); + + assertThat(view).isNotNull(); + verify(events).saveAndFlush(existing); + } + + @Test + void update_with_stale_version_throws_conflict_without_saving() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5 + when(events.findById(id)).thenReturn(Optional.of(existing)); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, 2L, null, null); // stale: 2 != 5 + + assertThatThrownBy(() -> service.update(id, request, secondEditor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT); + verify(events, never()).saveAndFlush(any()); + } + + @Test + void update_translates_concurrent_flush_lock_failure_to_TIMELINE_EVENT_CONFLICT() { + // Native @Version backstop: an in-sync token passes the explicit guard, but a genuinely + // concurrent flush makes saveAndFlush throw — it must still surface as a 409, not a 500. + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); // version 5 + when(events.findById(id)).thenReturn(Optional.of(existing)); + when(events.saveAndFlush(any())) + .thenThrow(new ObjectOptimisticLockingFailureException(TimelineEvent.class, id)); + TimelineEventRequest request = new TimelineEventRequest( + "Updated", EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, 5L, null, null); // in-sync, passes the guard + + assertThatThrownBy(() -> service.update(id, request, secondEditor)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT); + } + + // --- delete / getEvent --- + + @Test + void delete_removes_existing_event() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + + service.delete(id); + + verify(events).delete(existing); + } + + @Test + void delete_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.delete(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } + + @Test + void getEvent_returns_view_for_existing_event() { + UUID id = UUID.randomUUID(); + TimelineEvent existing = existingEvent(id, UUID.randomUUID()); + when(events.findById(id)).thenReturn(Optional.of(existing)); + + TimelineEventView view = service.getEvent(id); + + assertThat(view.id()).isEqualTo(id); + assertThat(view.title()).isEqualTo("Original"); + } + + @Test + void getEvent_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() { + UUID id = UUID.randomUUID(); + when(events.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEvent(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.TIMELINE_EVENT_NOT_FOUND); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dcc7acd2..e676b42f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C | `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own | | `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic | | `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities | -| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. | +| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. Timeline domain codes: `TIMELINE_EVENT_NOT_FOUND`, `TIMELINE_EVENT_CONFLICT`, `TIMELINE_TITLE_TOO_LONG`, plus a generic `CONFLICT` (409 optimistic-lock backstop). | | `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` | | `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` | | `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers | diff --git a/docs/adr/041-renovate-runner-setup.md b/docs/adr/041-renovate-runner-setup.md new file mode 100644 index 00000000..41a17fcd --- /dev/null +++ b/docs/adr/041-renovate-runner-setup.md @@ -0,0 +1,123 @@ +# ADR-041 — Renovate runner stand-up: two-token model, OSV surfacing, digest pinning + +**Date:** 2026-06-13 +**Status:** Accepted +**Issue:** [#818](https://git.raddatz.cloud/marcel/familienarchiv/issues/818) + +--- + +## Context + +Issue #817 (esbuild/cookie advisory) revealed that `main` had no early-warning +mechanism for newly-published advisories. An advisory landed against already-pinned +versions, turned the `npm audit --audit-level=high --omit=dev` gate red on `main`, +and then ambushed the next unrelated PR (#774). The author who hit it did not cause +it and had no warning. + +`renovate.json` existed but `renovatebot` had never actually run: there was no +`.gitea/workflows/renovate.yml` and zero Renovate-authored PRs in the repo's entire +history. The three `packageRules` (bucket4j / tiptap / privileged-digest) were +silently inert. + +This ADR records the **negative space** — why specific design choices were made, +so future maintainers do not "tidy up" toward a worse outcome. + +--- + +## Decision + +### Why there is no auto-provided `GITEA_TOKEN` + +Self-hosted Gitea runners do not auto-inject a `GITEA_TOKEN` equivalent. +`docs/infrastructure/ci-gitea.md` (and its current line ~251) explicitly states the +token "must be created manually." No existing workflow in this repo references +`GITEA_TOKEN` for API calls — only for container registry auth (`docker login`). +Both `RENOVATE_TOKEN` and `NIGHTLY_AUDIT_TOKEN` must be manually provisioned as +Gitea secrets by a repository admin. + +### Why two tokens, not one + +The two jobs have different blast radii on token compromise: + +| Token | Scopes | Used by | +|-------|--------|---------| +| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | Renovate — must read/write files and open PRs | +| `NIGHTLY_AUDIT_TOKEN` | `issues` only | Nightly audit — only needs to file a tracking issue | + +The nightly job's token appears in step `env:` and is passed to `curl -H`. A leak via +runner logs, process arguments, or a misconfigured step would expose the token. +An `issues`-only token cannot push branches, open PRs, or read repository contents — +the leaked token's blast radius is limited to creating/editing issues. + +A single broad token would give any leak path full `contents` + `pull_request` write +access to the repository. That risk is asymmetric with the upside (one fewer secret). + +Both tokens belong to one dedicated bot account (consistent authorship; one identity +to audit and rotate). **Branch protection on `main` must forbid the bot pushing +directly**, because a `contents`-scoped token can push to any unprotected branch. + +### Why the Renovate action is digest-pinned + +`renovatebot/github-action` executes with the `RENOVATE_TOKEN` in scope. That token +carries `contents` + `pull_request` + `issues` — enough to read files, open PRs, and +write issues. An unpinned `@v40` tag can be re-pointed by the upstream maintainer +(or a compromised maintainer account) at any time. A pinned digest (`@`) cannot +be silently modified; the SHA is immutable. This is the same threat model applied to +all privileged CI steps in this repo (see the `matchFileNames` rule in `renovate.json` +for `.gitea/workflows/**`). + +Renovate itself will open a PR to bump the digest when a new release ships, which is +the intended update path. + +### Why `osvVulnerabilityAlerts` is the load-bearing detector on Gitea + +Renovate's `vulnerabilityAlerts` config key triggers off a *platform* vulnerability +graph. GitHub exposes the GitHub Advisory Database via its API; **Gitea does not +expose an equivalent vulnerability graph**. On self-hosted Gitea, `vulnerabilityAlerts` +is effectively a label carrier — it attaches the configured labels to PRs that +`osvVulnerabilityAlerts` already detected, but it is not an independent detector. + +`osvVulnerabilityAlerts: true` is the load-bearing flag: Renovate queries +[OSV.dev](https://osv.dev) directly (platform-agnostic). The runner host must be able +to reach OSV.dev over HTTPS — if egress is filtered, allow `osv.dev:443` or the flag +silently no-ops. + +### Why the root `schedule` does not mute security PRs + +`"schedule": ["before 6am on monday"]` in `renovate.json` batches **routine** dependency +updates (version bumps outside any security context) to a weekly window. This reduces +noise from routine update PRs while still allowing review before merge. + +**Security and vulnerability PRs bypass the schedule by design** — Renovate raises +them immediately regardless of the schedule window. A future "tidy-up" that removes +or widens the schedule cannot mute vulnerability alerts; this is worth stating +explicitly to prevent that misunderstanding. + +### Why `lockFileMaintenance` has no `automerge` + +`lockFileMaintenance` refreshes transitive pins weekly so the dependency tree drifts +into fewer advisories over time. It is explicitly set without `automerge: true` because +a weekly transitive pin refresh can silently break the build if a transitive dep +introduces a breaking change. These PRs are small and should be reviewed. + +### Why there is no entry in `l2-containers.puml` + +`docs/architecture/c4/l2-containers.puml` documents long-lived infrastructure +containers (services that run continuously). Renovate is a scheduled CI job that runs +on a Gitea Actions runner and exits — it is not a long-lived container. Adding it to +the container diagram would misrepresent the architecture. This omission is deliberate, +not an oversight. + +--- + +## Consequences + +- Newly-published advisories against our frontend dependencies are surfaced within + one day (daily Renovate cron) rather than at the next contributor PR. +- A nightly `npm audit` job provides an independent signal for dev-dependency advisories + that Renovate may not cover via OSV. +- Two secrets (`RENOVATE_TOKEN`, `NIGHTLY_AUDIT_TOKEN`) must be manually provisioned + and rotated annually (or on suspected compromise). See + `docs/infrastructure/ci-gitea.md` for the runbook. +- The bot account must be kept active and branch protection on `main` must forbid + it pushing directly. These are operational prerequisites, not code invariants. diff --git a/docs/infrastructure/ci-gitea.md b/docs/infrastructure/ci-gitea.md index 6d99f694..871eec78 100644 --- a/docs/infrastructure/ci-gitea.md +++ b/docs/infrastructure/ci-gitea.md @@ -462,3 +462,82 @@ jobs: name: e2e-results path: frontend/test-results/e2e/ ``` + +--- + +## Renovate + Nightly Audit — Token Model + +> See [ADR-041](../adr/041-renovate-runner-setup.md) for full rationale. + +### Two-token model + +This repo uses two separate tokens for automated dependency work, both manually +provisioned as Gitea secrets. There is **no auto-provided `GITEA_TOKEN`** on +self-hosted Gitea runners — it must be created manually (see §Context Variable Names +table above). + +| Secret | Scopes | Job | Reason | +|--------|--------|-----|--------| +| `RENOVATE_TOKEN` | `contents` + `pull_request` + `issues` | `renovate.yml` | Renovate needs to read/write files and open PRs | +| `NIGHTLY_AUDIT_TOKEN` | `issues` only | `nightly.yml` → `npm-audit` job | Only needs to file a tracking issue; an `issues`-only token cannot push branches or read contents — limits blast radius on leak | + +Both tokens belong to a single dedicated bot account. **Branch protection on `main` +must forbid the bot pushing directly**, because a `contents`-scoped token can push +to any unprotected branch. + +### PAT rotation cadence + +Rotate both tokens: +- **Annually** (calendar reminder) +- **Immediately** on suspected compromise (runner log leak, accidental `set -x`, etc.) + +Tokens are stored exclusively as Gitea secrets. Never commit them to `.env` files or +log them. The nightly audit step passes its token via `env:` at the step level, reads +it as `$NIGHTLY_AUDIT_TOKEN` in the shell, and never runs the API `curl` under +`set -x`. + +### OSV vs platform alerts on Gitea + +Renovate's `vulnerabilityAlerts` config key requires a *platform* vulnerability graph. +**Gitea does not expose one** — it has no equivalent to the GitHub Advisory Database +API. On this runner, `vulnerabilityAlerts` is a label carrier only: it attaches +`security` and `P1-high` labels to PRs that `osvVulnerabilityAlerts` already raised. + +`osvVulnerabilityAlerts: true` is the load-bearing detector. Renovate queries +[OSV.dev](https://osv.dev) directly, which works regardless of platform. The runner +host must be able to reach `osv.dev:443`. If egress is filtered and OSV.dev is +unreachable, the flag silently no-ops — verify egress when standing up the runner. + +### Nightly audit vs PR gate (divergence) + +| Gate | Command | Dev deps | When | +|------|---------|----------|------| +| PR gate (`ci.yml`) | `npm audit --audit-level=high --omit=dev` | ❌ excluded | Every PR | +| Nightly audit (`nightly.yml`) | `npm audit --audit-level=high` | ✅ included | Nightly + `workflow_dispatch` | + +The nightly job is **deliberately broader** — it catches dev-tooling advisories +(esbuild, Vite, vitest, etc.) that the PR gate ignores. A red nightly audit job does +**not** mean the PR gate is broken; the two signals are independent. + +### Runbook: nightly-opened tracking issue + +When the `npm-audit` job opens or updates the tracking issue +"Nightly npm audit: high-severity advisory": + +1. **Triage severity.** Check the advisory page (link in the issue body). Is it + exploitable in production? A dev-only dep (e.g. esbuild, prettier) has no + production attack surface — treat as low urgency. + +2. **Pin or upgrade.** If a non-breaking upgrade is available, update + `frontend/package.json` and regenerate the lockfile. Open a PR. + +3. **Override if justified.** If the advisory does not apply (dev-only dep, no + exploitable path), add an `npm audit` override in `package.json`: + ```json + "overrides": { "esbuild": ">=0.25.4" } + ``` + Document the rationale in the PR body. See #817 for the reference decision tree. + +4. **Close the tracking issue** once the advisory is resolved or overridden and the + nightly job runs clean (verify via the `✅ npm audit clean` heartbeat in the job + summary). diff --git a/docs/infrastructure/self-hosted-catalogue.md b/docs/infrastructure/self-hosted-catalogue.md index fc9a1c61..c197db0c 100644 --- a/docs/infrastructure/self-hosted-catalogue.md +++ b/docs/infrastructure/self-hosted-catalogue.md @@ -151,7 +151,7 @@ receivers: name: Renovate on: schedule: - - cron: '0 3 * * 1' # every Monday at 3am + - cron: '0 3 * * *' # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day workflow_dispatch: jobs: @@ -160,32 +160,58 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run Renovate - uses: renovatebot/github-action@v40 + # Pin by digest — this action holds contents+pull_request+issues token; + # an unpinned tag is a supply-chain risk. Update digest + renovate-version + # together when Renovate publishes a new release. + uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15 with: configurationFile: renovate.json - token: ${{ secrets.GITEA_TOKEN }} - renovate-version: latest + token: ${{ secrets.RENOVATE_TOKEN }} + renovate-version: "46.1.15" + env: + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: https://gitea.example.com # replace with your Gitea URL + RENOVATE_REPOSITORIES: '["org/repo"]' # replace with your repo slug + LOG_LEVEL: info ``` +> **Token:** `RENOVATE_TOKEN` must be a PAT on a dedicated bot account with scopes +> `contents` + `pull_request` + `issues`. **Do not reuse** `GITEA_TOKEN` — that variable +> is not auto-provided on self-hosted Gitea runners and must be manually created anyway; +> using a single broad token violates least-privilege. See ADR-041. + ### Renovate Configuration +The `renovate.json` in the repo root carries only dependency rules — platform and +endpoint config is injected via `env:` in the workflow above. Keep the two concerns +separate so the config file remains portable. + ```json -// renovate.json { - "platform": "gitea", - "endpoint": "https://gitea.example.com", - "repositories": ["org/familienarchiv"], - "automerge": true, - "automergeType": "pr", + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "osvVulnerabilityAlerts": true, + "dependencyDashboard": true, + "schedule": ["before 6am on monday"], + "vulnerabilityAlerts": { + "labels": ["security", "P1-high"] + }, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on monday"] + }, "packageRules": [ { - "matchUpdateTypes": ["patch"], - "automerge": true + "matchPackageNames": ["com.example:my-dep"], + "automerge": true, + "matchUpdateTypes": ["patch"] } ] } ``` +> **Do not add `automerge: true` at the root.** Security and digest-bump PRs should +> always be reviewed manually. Per-rule `automerge` on patch-level routine deps is fine. + --- ## Secrets Management -- age + git-crypt diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 6fae284d..c306a1a3 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1237,6 +1237,10 @@ "error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).", "error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).", "error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).", + "error_timeline_event_not_found": "Zeitleistenereignis nicht gefunden.", + "error_timeline_event_conflict": "Dieses Ereignis wurde zwischenzeitlich geändert. Bitte neu laden.", + "error_timeline_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).", + "error_conflict": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden.", "person_unknown": "[Unbekannt]", "error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.", "error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden." diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0c51cafe..9f3bb8e0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1237,6 +1237,10 @@ "error_journey_note_too_long": "The note is too long (maximum 2000 characters).", "error_geschichte_title_too_long": "The title is too long (maximum 255 characters).", "error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).", + "error_timeline_event_not_found": "Timeline event not found.", + "error_timeline_event_conflict": "This event was changed in the meantime. Please reload.", + "error_timeline_title_too_long": "The title is too long (maximum 255 characters).", + "error_conflict": "The record was changed in the meantime. Please reload.", "person_unknown": "[Unknown]", "error_journey_document_already_added": "This letter is already included in the reading journey.", "error_geschichte_type_immutable": "The type of a story cannot be changed after creation." diff --git a/frontend/messages/es.json b/frontend/messages/es.json index d1695d15..d4d8544d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1237,6 +1237,10 @@ "error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).", "error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).", "error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).", + "error_timeline_event_not_found": "Evento de la línea de tiempo no encontrado.", + "error_timeline_event_conflict": "Este evento se modificó mientras tanto. Vuelve a cargar.", + "error_timeline_title_too_long": "El título es demasiado largo (máximo 255 caracteres).", + "error_conflict": "El registro se modificó mientras tanto. Vuelve a cargar.", "person_unknown": "[Desconocido]", "error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.", "error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación." diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 44151d3d..f275c9f2 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -52,6 +52,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/timeline/events/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getEvent"]; + put: operations["update"]; + post?: never; + delete: operations["delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tags/{id}": { parameters: { query?: never; @@ -232,6 +248,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/timeline/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tags/{id}/merge": { parameters: { query?: never; @@ -433,7 +465,7 @@ export interface paths { }; get: operations["list"]; put?: never; - post: operations["create"]; + post: operations["create_1"]; delete?: never; options?: never; head?: never; @@ -834,10 +866,10 @@ export interface paths { get: operations["getById"]; put?: never; post?: never; - delete: operations["delete"]; + delete: operations["delete_1"]; options?: never; head?: never; - patch: operations["update"]; + patch: operations["update_1"]; trace?: never; }; "/api/geschichten/{id}/items/{itemId}": { @@ -1691,6 +1723,61 @@ export interface components { notifyOnReply?: boolean; notifyOnMention?: boolean; }; + TimelineEventRequest: { + title: string; + /** @enum {string} */ + type: "PERSONAL" | "HISTORICAL"; + /** Format: date */ + eventDate: string; + /** @enum {string} */ + precision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + eventDateEnd?: string; + description?: string; + /** Format: int64 */ + version?: number; + personIds?: string[]; + documentIds?: string[]; + }; + DocumentRef: { + /** Format: uuid */ + id: string; + title: string; + /** Format: date */ + documentDate?: string; + }; + PersonView: { + /** Format: uuid */ + id: string; + firstName?: string; + lastName?: string; + }; + TimelineEventView: { + /** Format: uuid */ + id: string; + title: string; + /** @enum {string} */ + type: "PERSONAL" | "HISTORICAL"; + /** Format: date */ + eventDate: string; + /** @enum {string} */ + precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + eventDateEnd?: string; + description?: string; + /** Format: int64 */ + version: number; + /** Format: uuid */ + createdBy: string; + /** Format: date-time */ + createdAt: string; + /** Format: uuid */ + updatedBy: string; + /** Format: date-time */ + updatedAt: string; + persons: components["schemas"]["PersonView"][]; + documents: components["schemas"]["DocumentRef"][]; + }; TagUpdateDTO: { name?: string; /** Format: uuid */ @@ -2060,12 +2147,6 @@ export interface components { /** Format: date-time */ updatedAt: string; }; - PersonView: { - /** Format: uuid */ - id: string; - firstName?: string; - lastName?: string; - }; JourneyItemCreateDTO: { /** Format: uuid */ documentId?: string; @@ -2498,10 +2579,10 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { @@ -2885,6 +2966,74 @@ export interface operations { }; }; }; + getEvent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TimelineEventView"]; + }; + }; + }; + }; + update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TimelineEventRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TimelineEventView"]; + }; + }; + }; + }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; updateTag: { parameters: { query?: never; @@ -3325,6 +3474,30 @@ export interface operations { }; }; }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TimelineEventRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["TimelineEventView"]; + }; + }; + }; + }; mergeTag: { parameters: { query?: never; @@ -3754,7 +3927,7 @@ export interface operations { }; }; }; - create: { + create_1: { parameters: { query?: never; header?: never; @@ -4480,7 +4653,7 @@ export interface operations { }; }; }; - delete: { + delete_1: { parameters: { query?: never; header?: never; @@ -4500,7 +4673,7 @@ export interface operations { }; }; }; - update: { + update_1: { parameters: { query?: never; header?: never; diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 344fc08c..9bd6fd66 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -56,6 +56,9 @@ export type ErrorCode = | 'GESCHICHTE_TYPE_IMMUTABLE' | 'GESCHICHTE_TITLE_TOO_LONG' | 'GESCHICHTE_INTRO_TOO_LONG' + | 'TIMELINE_EVENT_NOT_FOUND' + | 'TIMELINE_EVENT_CONFLICT' + | 'TIMELINE_TITLE_TOO_LONG' | 'INVALID_CREDENTIALS' | 'SESSION_EXPIRED' | 'MISSING_CREDENTIALS' @@ -66,6 +69,7 @@ export type ErrorCode = | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' + | 'CONFLICT' | 'INTERNAL_ERROR'; export interface BackendError { @@ -194,6 +198,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_geschichte_title_too_long(); case 'GESCHICHTE_INTRO_TOO_LONG': return m.error_geschichte_intro_too_long(); + case 'TIMELINE_EVENT_NOT_FOUND': + return m.error_timeline_event_not_found(); + case 'TIMELINE_EVENT_CONFLICT': + return m.error_timeline_event_conflict(); + case 'TIMELINE_TITLE_TOO_LONG': + return m.error_timeline_title_too_long(); case 'INVALID_CREDENTIALS': return m.error_invalid_credentials(); case 'SESSION_EXPIRED': @@ -214,6 +224,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_batch_too_large(); case 'BULK_EDIT_TOO_MANY_IDS': return m.error_bulk_edit_too_many_ids(); + case 'CONFLICT': + return m.error_conflict(); default: return m.error_internal_error(); } diff --git a/renovate.json b/renovate.json index 2b4af645..bae03932 100644 --- a/renovate.json +++ b/renovate.json @@ -1,5 +1,15 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "osvVulnerabilityAlerts": true, + "dependencyDashboard": true, + "schedule": ["before 6am on monday"], + "vulnerabilityAlerts": { + "labels": ["security", "P1-high"] + }, + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on monday"] + }, "packageRules": [ { "description": "bucket4j-core is manually pinned outside the Spring BOM — track patch auto-merge, minor/major as PRs.", @@ -9,13 +19,13 @@ "matchUpdateTypes": ["patch"] }, { - "matchPackagePatterns": ["^@tiptap/"], + "matchPackageNames": ["/^@tiptap/"], "groupName": "tiptap", "automerge": false }, { "description": "Digest bumps for images used in privileged CI steps (--privileged --pid=host) must be reviewed manually — a compromised image has root-equivalent host access. Covers .gitea/actions/** too: the reload-caddy alpine digest now lives in a composite action (#603).", - "matchPaths": [".gitea/workflows/**", ".gitea/actions/**"], + "matchFileNames": [".gitea/workflows/**", ".gitea/actions/**"], "matchUpdateTypes": ["digest"], "automerge": false, "reviewersFromCodeOwners": false