Merge branch 'main' into docs/sdd-integration
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 4m7s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Failing after 52s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m14s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 30s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 4m7s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 5m1s
CI / fail2ban Regex (pull_request) Failing after 52s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m14s
SDD Gate / RTM Check (pull_request) Successful in 18s
SDD Gate / Contract Validate (pull_request) Successful in 30s
SDD Gate / Constitution Impact (pull_request) Successful in 18s
This commit is contained in:
@@ -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<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
|
||||
(tostring | .[0:500]) +
|
||||
"\n```\n\n</details>"
|
||||
' /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
|
||||
|
||||
44
.gitea/workflows/renovate.yml
Normal file
44
.gitea/workflows/renovate.yml
Normal file
@@ -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
|
||||
@@ -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 `<BackButton>` 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<ErrorResponse> 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<ErrorResponse> handleGeneric(Exception ex) {
|
||||
Sentry.captureException(ex);
|
||||
|
||||
@@ -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<Void> delete(@PathVariable UUID id) {
|
||||
timelineEventService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p><strong>{@code createdBy}/{@code updatedBy} are intentionally absent.</strong> 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 <em>update</em> only. This is a concurrency token, <strong>not</strong>
|
||||
* 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<UUID> personIds,
|
||||
@Size(max = 50) List<UUID> documentIds
|
||||
) {}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This explicit compare is the control — NOT {@code event.setVersion(clientVersion)} before
|
||||
* flush. Setting {@code @Version} on a <em>managed</em> 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<Person> resolvePersons(List<UUID> 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<UUID> distinct = new LinkedHashSet<>(ids);
|
||||
List<Person> 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<Document> resolveDocuments(List<UUID> 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<Document> 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<PersonView> persons = event.getPersons().stream()
|
||||
.map(p -> new PersonView(p.getId(), p.getFirstName(), p.getLastName()))
|
||||
.toList();
|
||||
List<DocumentRef> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<PersonView> persons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<DocumentRef> 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).
|
||||
*
|
||||
* <p>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
|
||||
) {}
|
||||
}
|
||||
@@ -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<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
handlerLogger.addAppender(appender);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> personIds, List<UUID> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<TimelineEvent> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
123
docs/adr/041-renovate-runner-setup.md
Normal file
123
docs/adr/041-renovate-runner-setup.md
Normal file
@@ -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 (`@<sha>`) 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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user