diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52499d2b..6bbdcb0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,9 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] + # Every pull request target, including immutable stack branches, receives the + # same exact-head, synthetic-merge, and buyer-readiness acceptance evidence. + pull_request: {} permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 099cab55..1cd82131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. +- `RECEIPT_V1` append-only artifact-deletion receipt foundation을 추가했습니다. 영구 `job_id`, tenant, artifact SHA-256, idempotency request, privacy-safe audit correlation, 단조 상태와 시각, controlled failure code를 하나의 불변 증거로 묶고 standalone in-memory 및 restart-replay file adapter를 같은 `ArtifactDeletionReceiptStore` 계약으로 제공합니다. HTTP 삭제·metadata tombstone·cleanup worker의 원자적 통합은 issue #263의 후속 slice로 유지합니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. @@ -25,6 +26,7 @@ ### Security - `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다. +- Deletion receipt ledger는 각 transition snapshot을 16 KiB 이하 strict UTF-8 `RECEIPT_V1` record로 검증하고 append 후 `FileChannel.force(true)`를 완료하기 전 durable 상태를 반환하지 않습니다. Record terminator는 host-independent fixed LF로 고정하고 commit delimiter로 취급하므로 non-empty unterminated final tail은 crash 또는 torn append의 미확정 증거로 간주해 startup에서 fail closed 처리하고 replay하지 않습니다. Replay는 `attempt_count`와 `last_attempt_at`의 동시 존재, 시각 순서, requested/tombstoned 상태의 zero-attempt 계약, retry/completion의 이전 attempt evidence 보존을 검증하며 pending-to-failed 전이만 count 증가를 허용합니다. malformed Base64URL, invalid timestamp/state/count, immutable identity conflict, non-monotonic time, unsafe failure detail, oversized line도 거부합니다. 이 증거는 아직 cleanup 완료를 의미하지 않으며 issue #263의 transactional outbox와 exact-generation worker가 통합될 때까지 incomplete cleanup finding을 유지합니다. - Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다. - Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다. - Maven XML 테스트 보고서 검증기는 파일당 16 MiB 상한을 적용하고 한 번의 제한된 읽기로 실제 입력 크기를 검증합니다. 테스트 코드가 보고서 파일을 교체하거나 확장해도 크기 사전검사와 파싱 사이의 경쟁 조건을 이용할 수 없습니다. diff --git a/docs/security/2026-08-06-durable-artifact-deletion-receipts.md b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md new file mode 100644 index 00000000..5098e6d5 --- /dev/null +++ b/docs/security/2026-08-06-durable-artifact-deletion-receipts.md @@ -0,0 +1,169 @@ +# Durable artifact-deletion receipt boundary + +- **Status:** Accepted foundation; HTTP mutation and cleanup-worker integration remain future slices. +- **Decision date:** 2026-08-06 +- **Owner issue:** #263 +- **Format version:** `RECEIPT_V1` + +## Decision + +Clearfolio records each accepted artifact-deletion lifecycle as immutable, +versioned receipt snapshots. The reference adapter is an append-only UTF-8 ledger +that forces each snapshot to storage before returning. A standalone caller may +use the in-memory constructor, while the Spring application defaults to +`data/artifact-deletion-receipts.log` so pending evidence can be replayed after a +process restart. + +This foundation does **not** yet change the administrative DELETE response, move +job tombstoning and receipt creation into one transaction, revoke signed links, +or run an artifact-cleanup worker. Those are separate bounded slices under +issue `#263`. Until they integrate, the existing delete path remains best effort +and the CodeRabbit incomplete-cleanup finding must remain open. + +## Immutable receipt identity + +One receipt identity contains: + +- `request_id`: deletion idempotency identifier; +- `tenant_id`: tenant that owned the conversion job; +- `job_id`: permanently reserved conversion-job identifier; +- `artifact_checksum`: lowercase SHA-256 digest for the exact artifact bytes; +- `audit_correlation_id`: privacy-safe correlation value, never a raw subject, + tenant secret, token, filename, or document value; +- `requested_at`: durable request time. + +A repeated request returns the existing object only when every immutable field +matches. A different request, tenant, artifact digest, audit correlation, or +request time for the same `job_id` fails closed. This complements #268's +permanent UUID reservation and prevents cleanup work from being rebound to a +new tenant or artifact generation. + +## Monotonic lifecycle + +```mermaid +stateDiagram-v2 + [*] --> DELETION_REQUESTED + DELETION_REQUESTED --> METADATA_TOMBSTONED + METADATA_TOMBSTONED --> ARTIFACT_CLEANUP_PENDING + ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_COMPLETED + ARTIFACT_CLEANUP_PENDING --> ARTIFACT_CLEANUP_FAILED + ARTIFACT_CLEANUP_FAILED --> ARTIFACT_CLEANUP_PENDING + ARTIFACT_CLEANUP_COMPLETED --> [*] +``` + +Every snapshot records `state_changed_at`. Time cannot move backward relative to +the previous durable transition. `ARTIFACT_CLEANUP_COMPLETED` is terminal. +Failures increment `attempt_count`, record `last_attempt_at`, and accept only a +controlled lowercase code matching `[a-z0-9_]{1,64}`. Exception messages, +storage paths, raw identifiers, stack traces, and document metadata are not +valid failure codes. + +Attempt evidence is internally consistent and immutable across replay: + +- `attempt_count == 0` requires absent `last_attempt_at`; +- `attempt_count > 0` requires a `last_attempt_at` no later than + `state_changed_at`; +- requested and metadata-tombstoned receipts cannot claim cleanup attempts; +- only pending-to-failed increments the count and advances `last_attempt_at`; +- retry-pending and completed snapshots preserve the prior count and latest + attempt instant exactly. + +A replayed snapshot that erases, rewrites, advances, or invents attempt evidence +outside those transitions fails closed. + +## Persistence and replay + +The reference file adapter applies the following contract: + +1. serialize one complete `RECEIPT_V1` snapshot; +2. encode variable text fields as Base64URL and separate fields with tabs; +3. reject records longer than 16 KiB during replay; +4. write the full record and a fixed ASCII LF (`0x0A`) terminator through one + append-only file channel, treating LF as the record commit delimiter; +5. call `FileChannel.force(true)` before exposing the transition as durable; +6. replay only delimiter-committed records with bounded, strict UTF-8 decoding; +7. reject any non-empty unterminated final tail instead of interpreting a + potentially torn or pre-force append as durable evidence; +8. validate record shape, immutable identity, state timestamps, attempt evidence, + and legal predecessor-to-successor transitions; +9. fail application construction on malformed, conflicting, oversized, + unterminated, or non-monotonic evidence. + +A missing ledger file means an empty store. A malformed existing file or +unterminated final record is not silently skipped, truncated, or replayed. +Startup does not mutate audit evidence as an implicit recovery action; operators +must preserve the original bytes and use a controlled, reviewable recovery +procedure. Completed receipts are retained for audit and idempotency but +excluded from the pending-work view. + +## Modular and MSA contract + +`ArtifactDeletionReceiptStore` is the versioned service boundary. An external +PostgreSQL, event-store, or message-broker adapter may replace the file ledger, +but it must preserve: + +- one permanently reserved `job_id` lifecycle; +- exact immutable request identity; +- tenant and artifact-digest binding; +- monotonic state and time; +- idempotent duplicate requests; +- controlled privacy-safe failure evidence; +- restart-replay of pending and failed work; +- terminal completion retention; +- fail-closed conflict behavior. + +Future database objects must use descriptive two-or-more-word `snake_case` +names, including `deletion_request`, `deletion_receipt`, +`artifact_cleanup_task`, `job_tombstone`, and `audit_event`. + +## Required next slices + +### Atomic tombstone and outbox + +Create the receipt, metadata tombstone, and cleanup task in one durable +transaction. A crash must not leave a tombstone without a replayable task or a +task without an authorized tombstone. + +### Exact-generation cleanup worker + +The worker must revalidate tenant, `job_id`, artifact checksum or storage +generation, receipt state, and revocation state before deletion. Stale receipts +must be rejected rather than deleting newer bytes. + +### Truthful API and accessible UI + +The product must distinguish accepted, cleanup pending, cleanup failed, and +completed states. It must never return a completed deletion while confidential +artifact bytes or valid signed links remain available. + +## Verification requirements + +- exact duplicate request object identity; +- conflicting same-job requests fail closed; +- every legal and illegal transition; +- nondecreasing transition timestamps; +- internally consistent and transition-preserved attempt evidence; +- controlled failure-code validation; +- attempt-count persistence across retries and restart; +- strict UTF-8, malformed-line, oversized-line, conflicting-replay, and + unterminated-final-tail rejection; +- fixed host-independent LF record delimiters; +- completed receipts excluded from pending work but retained after restart; +- production line and branch coverage of 100%; +- complete beginner-readable public Javadocs; +- exact-head CI, Security Scan, SAST, fuzzing, independent review, and protected + merge evidence before integration. + +## References + +Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110; +STD 97). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9110 + +National Institute of Standards and Technology. (2024). *Protecting controlled +unclassified information in nonfederal systems and organizations* (NIST Special +Publication 800-171, Revision 3). U.S. Department of Commerce. +https://doi.org/10.6028/NIST.SP.800-171r3 + +OWASP Foundation. (2023). *API1:2023 broken object level authorization*. In +*OWASP API Security Top 10—2023*. +https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ diff --git a/scripts/test_ci_workflow_stack_coverage.py b/scripts/test_ci_workflow_stack_coverage.py new file mode 100644 index 00000000..8a3af8b7 --- /dev/null +++ b/scripts/test_ci_workflow_stack_coverage.py @@ -0,0 +1,24 @@ +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" + + +def test_ci_runs_for_every_pull_request_base() -> None: + """Stacked pull requests must receive the same exact-head CI as main-bound PRs.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + + assert " pull_request: {}" in workflow + assert " pull_request:\n branches: [main]" not in workflow + + +def test_ci_preserves_exact_head_and_synthetic_merge_evidence() -> None: + """Broadening PR coverage must not weaken exact-head or merge verification.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + + exact_head_expression = "github.event.pull_request.head.sha || github.sha" + assert workflow.count(exact_head_expression) >= 4 + assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in workflow + assert "name: Maven merge compatibility" in workflow + assert "name: Buyer-readiness script tests" in workflow diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java new file mode 100644 index 00000000..b747da00 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedger.java @@ -0,0 +1,447 @@ +package com.clearfolio.viewer.lifecycle; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.UnaryOperator; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; + +/** + * Append-only durable store for tenant-bound artifact-deletion receipts. + * + *

The file-backed mode writes a complete immutable receipt snapshot for each + * legal transition and forces the file channel before returning. Startup replay + * uses bounded strict UTF-8 lines and validates immutable identity, timestamps, + * and monotonic transitions before exposing pending work. An empty path selects + * the standalone in-memory adapter.

+ */ +@Repository +public class ArtifactDeletionLedger implements ArtifactDeletionReceiptStore { + + static final int MAX_LEDGER_LINE_BYTES = 16 * 1024; + + private static final String RECEIPT = "RECEIPT_V1"; + private static final String NULL_FIELD = "-"; + private static final int FIELD_COUNT = 13; + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + private static final Comparator RECEIPT_ORDER = Comparator + .comparing(ArtifactDeletionReceipt::requestedAt) + .thenComparing(receipt -> receipt.jobId().toString()); + + private final ConcurrentMap receiptsByJobId = new ConcurrentHashMap<>(); + private final Path ledgerPath; + + /** + * Creates a standalone in-memory deletion ledger. + */ + public ArtifactDeletionLedger() { + this((Path) null); + } + + /** + * Creates a deletion ledger with optional append-only file persistence. + * + * @param configuredPath configured ledger path, or blank for in-memory mode + */ + @Autowired + public ArtifactDeletionLedger( + @Value("${clearfolio.artifact-deletion-ledger.path:data/artifact-deletion-receipts.log}") + String configuredPath + ) { + this(pathOf(configuredPath)); + } + + ArtifactDeletionLedger(Path ledgerPath) { + this.ledgerPath = ledgerPath; + load(); + } + + /** + * {@inheritDoc} + */ + @Override + public synchronized ArtifactDeletionReceipt request( + UUID requestId, + String tenantId, + UUID jobId, + String artifactChecksum, + String auditCorrelationId, + Instant requestedAt + ) { + ArtifactDeletionReceipt requested = new ArtifactDeletionReceipt( + requestId, + tenantId, + jobId, + artifactChecksum, + auditCorrelationId, + requestedAt, + requestedAt, + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + null, + null + ); + ArtifactDeletionReceipt existing = receiptsByJobId.get(requested.jobId()); + if (existing != null) { + if (existing.hasSameIdentity(requested)) { + return existing; + } + throw conflictingReceipt(); + } + + append(requested); + receiptsByJobId.put(requested.jobId(), requested); + return requested; + } + + /** + * {@inheritDoc} + */ + @Override + public ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt) { + return transition(jobId, current -> current.markMetadataTombstoned(transitionedAt)); + } + + /** + * {@inheritDoc} + */ + @Override + public ArtifactDeletionReceipt markCleanupPending(UUID jobId, Instant transitionedAt) { + return transition(jobId, current -> current.markCleanupPending(transitionedAt)); + } + + /** + * {@inheritDoc} + */ + @Override + public ArtifactDeletionReceipt recordCleanupFailure( + UUID jobId, + String failureCode, + Instant attemptedAt + ) { + return transition( + jobId, + current -> current.recordCleanupFailure(failureCode, attemptedAt) + ); + } + + /** + * {@inheritDoc} + */ + @Override + public ArtifactDeletionReceipt markCleanupCompleted(UUID jobId, Instant completedAt) { + return transition(jobId, current -> current.markCleanupCompleted(completedAt)); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByJobId(UUID jobId) { + if (jobId == null) { + return Optional.empty(); + } + return Optional.ofNullable(receiptsByJobId.get(jobId)); + } + + /** + * {@inheritDoc} + */ + @Override + public List pendingReceipts() { + return receiptsByJobId.values().stream() + .filter(receipt -> !receipt.isCompleted()) + .sorted(RECEIPT_ORDER) + .toList(); + } + + /** + * {@inheritDoc} + */ + @Override + public int pendingCount() { + return Math.toIntExact(receiptsByJobId.values().stream() + .filter(receipt -> !receipt.isCompleted()) + .count()); + } + + private synchronized ArtifactDeletionReceipt transition( + UUID jobId, + UnaryOperator transition + ) { + UUID requiredJobId = Objects.requireNonNull(jobId, "jobId"); + ArtifactDeletionReceipt current = receiptsByJobId.get(requiredJobId); + if (current == null) { + throw missingReceipt(); + } + ArtifactDeletionReceipt updated = Objects.requireNonNull( + transition.apply(current), + "updatedReceipt" + ); + if (!current.hasSameIdentity(updated)) { + throw conflictingReceipt(); + } + append(updated); + receiptsByJobId.put(requiredJobId, updated); + return updated; + } + + private void load() { + if (ledgerPath == null) { + return; + } + try (InputStream input = new BufferedInputStream(Files.newInputStream(ledgerPath))) { + readBoundedLines(input); + } catch (java.nio.file.NoSuchFileException exception) { + // A missing ledger represents an empty durable store. + } catch (IOException exception) { + throw new IllegalStateException("artifact deletion ledger cannot be loaded", exception); + } + } + + private void readBoundedLines(InputStream input) throws IOException { + ByteArrayOutputStream line = new ByteArrayOutputStream(); + int currentByte; + while ((currentByte = input.read()) != -1) { + if (currentByte == '\n') { + replayBytes(line.toByteArray()); + line.reset(); + continue; + } + if (line.size() >= MAX_LEDGER_LINE_BYTES) { + throw oversizedLine(); + } + line.write(currentByte); + } + if (line.size() > 0) { + throw invalidLine(); + } + } + + private void replayBytes(byte[] bytes) { + int length = bytes.length; + if (length > 0 && bytes[length - 1] == '\r') { + length--; + } + if (length == 0) { + throw invalidLine(); + } + replayLine(strictUtf8(Arrays.copyOf(bytes, length))); + } + + private void replayLine(String line) { + ArtifactDeletionReceipt replayed = parse(line); + ArtifactDeletionReceipt current = receiptsByJobId.get(replayed.jobId()); + if (current == null) { + if (replayed.state() != ArtifactDeletionState.DELETION_REQUESTED) { + throw invalidLine(); + } + receiptsByJobId.put(replayed.jobId(), replayed); + return; + } + if (!current.hasSameIdentity(replayed)) { + throw invalidLine(); + } + validateReplayTransition(current, replayed); + receiptsByJobId.put(replayed.jobId(), replayed); + } + + private void append(ArtifactDeletionReceipt receipt) { + if (ledgerPath == null) { + return; + } + String serialized = serialize(receipt); + byte[] bytes = (serialized + "\n").getBytes(StandardCharsets.UTF_8); + try { + Path absolutePath = ledgerPath.toAbsolutePath(); + Files.createDirectories(absolutePath.getParent()); + try (FileChannel channel = FileChannel.open( + absolutePath, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND + )) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + } catch (IOException exception) { + throw new IllegalStateException("artifact deletion ledger cannot be written", exception); + } + } + + private static ArtifactDeletionReceipt parse(String line) { + try { + String[] fields = line.split("\\t", -1); + if (fields.length != FIELD_COUNT || !RECEIPT.equals(fields[0])) { + throw invalidLine(); + } + return new ArtifactDeletionReceipt( + UUID.fromString(fields[1]), + decodeRequired(fields[2]), + UUID.fromString(fields[3]), + decodeRequired(fields[4]), + decodeRequired(fields[5]), + Instant.parse(fields[6]), + Instant.parse(fields[7]), + ArtifactDeletionState.valueOf(fields[8]), + Integer.parseInt(fields[9]), + optionalInstant(fields[10]), + optionalInstant(fields[11]), + decodeOptional(fields[12]) + ); + } catch (IllegalArgumentException | DateTimeException exception) { + throw invalidLine(exception); + } + } + + private static String serialize(ArtifactDeletionReceipt receipt) { + return String.join( + "\t", + RECEIPT, + receipt.requestId().toString(), + encode(receipt.tenantId()), + receipt.jobId().toString(), + encode(receipt.artifactChecksum()), + encode(receipt.auditCorrelationId()), + receipt.requestedAt().toString(), + receipt.stateChangedAt().toString(), + receipt.state().name(), + Integer.toString(receipt.attemptCount()), + optionalInstant(receipt.lastAttemptAt()), + optionalInstant(receipt.completedAt()), + encodeOptional(receipt.failureCode()) + ); + } + + private static void validateReplayTransition( + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed + ) { + if (replayed.stateChangedAt().isBefore(current.stateChangedAt())) { + throw invalidLine(); + } + boolean valid = switch (current.state()) { + case DELETION_REQUESTED -> replayed.state() == ArtifactDeletionState.METADATA_TOMBSTONED + && hasSameAttemptEvidence(current, replayed); + case METADATA_TOMBSTONED -> replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING + && hasSameAttemptEvidence(current, replayed); + case ARTIFACT_CLEANUP_PENDING -> ( + replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED + && hasSameAttemptEvidence(current, replayed) + ) || ( + replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED + && replayed.attemptCount() == current.attemptCount() + 1 + ); + case ARTIFACT_CLEANUP_FAILED -> replayed.state() == ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING + && hasSameAttemptEvidence(current, replayed); + case ARTIFACT_CLEANUP_COMPLETED -> false; + }; + if (!valid) { + throw invalidLine(); + } + } + + private static boolean hasSameAttemptEvidence( + ArtifactDeletionReceipt current, + ArtifactDeletionReceipt replayed + ) { + return replayed.attemptCount() == current.attemptCount() + && Objects.equals(replayed.lastAttemptAt(), current.lastAttemptAt()); + } + + private static String strictUtf8(byte[] bytes) { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw invalidLine(exception); + } + } + + private static String encode(String value) { + return ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String encodeOptional(String value) { + return value == null ? NULL_FIELD : encode(value); + } + + private static String decodeRequired(String field) { + String value = decodeOptional(field); + if (value == null || value.isBlank()) { + throw invalidLine(); + } + return value; + } + + private static String decodeOptional(String field) { + if (NULL_FIELD.equals(field)) { + return null; + } + return strictUtf8(DECODER.decode(field)); + } + + private static String optionalInstant(Instant value) { + return value == null ? NULL_FIELD : value.toString(); + } + + private static Instant optionalInstant(String value) { + return NULL_FIELD.equals(value) ? null : Instant.parse(value); + } + + private static Path pathOf(String value) { + String normalized = value == null ? null : value.strip(); + return normalized == null || normalized.isEmpty() ? null : Path.of(normalized); + } + + private static IllegalStateException conflictingReceipt() { + return new IllegalStateException("artifact deletion receipt conflicts with an existing lifecycle"); + } + + private static IllegalStateException missingReceipt() { + return new IllegalStateException("artifact deletion receipt not found"); + } + + private static IllegalStateException oversizedLine() { + return new IllegalStateException("artifact deletion ledger line exceeds the configured bound"); + } + + private static IllegalStateException invalidLine() { + return new IllegalStateException("artifact deletion ledger contains an invalid line"); + } + + private static IllegalStateException invalidLine(Throwable cause) { + return new IllegalStateException("artifact deletion ledger contains an invalid line", cause); + } +} diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java new file mode 100644 index 00000000..6e3893e9 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceipt.java @@ -0,0 +1,276 @@ +package com.clearfolio.viewer.lifecycle; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Immutable evidence for one tenant-bound artifact-deletion lifecycle. + * + * @param requestId idempotency identifier supplied for the deletion request + * @param tenantId tenant that owned the deleted conversion job + * @param jobId permanently reserved conversion-job identifier + * @param artifactChecksum lowercase SHA-256 digest binding cleanup to one artifact generation + * @param auditCorrelationId privacy-safe identifier joining lifecycle audit evidence + * @param requestedAt instant when the deletion request became durable + * @param stateChangedAt instant when the current state became durable + * @param state current monotonic lifecycle state + * @param attemptCount number of failed cleanup attempts + * @param lastAttemptAt instant of the latest failed cleanup attempt, when any + * @param completedAt instant when cleanup became terminally complete, when any + * @param failureCode controlled non-sensitive failure code, when cleanup failed + */ +public record ArtifactDeletionReceipt( + UUID requestId, + String tenantId, + UUID jobId, + String artifactChecksum, + String auditCorrelationId, + Instant requestedAt, + Instant stateChangedAt, + ArtifactDeletionState state, + int attemptCount, + Instant lastAttemptAt, + Instant completedAt, + String failureCode +) { + + private static final int MAX_IDENTIFIER_LENGTH = 256; + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + private static final Pattern FAILURE_CODE_PATTERN = Pattern.compile("[a-z0-9_]{1,64}"); + + /** + * Validates one immutable receipt snapshot. + */ + public ArtifactDeletionReceipt { + requestId = Objects.requireNonNull(requestId, "requestId"); + tenantId = requireText(tenantId, "tenantId"); + jobId = Objects.requireNonNull(jobId, "jobId"); + artifactChecksum = requireChecksum(artifactChecksum); + auditCorrelationId = requireText(auditCorrelationId, "auditCorrelationId"); + requestedAt = Objects.requireNonNull(requestedAt, "requestedAt"); + stateChangedAt = Objects.requireNonNull(stateChangedAt, "stateChangedAt"); + state = Objects.requireNonNull(state, "state"); + if (stateChangedAt.isBefore(requestedAt)) { + throw new IllegalArgumentException("stateChangedAt must not precede requestedAt"); + } + if (attemptCount < 0) { + throw new IllegalArgumentException("attemptCount must not be negative"); + } + failureCode = normalizeOptional(failureCode); + validateStateFields( + requestedAt, + stateChangedAt, + state, + attemptCount, + lastAttemptAt, + completedAt, + failureCode + ); + } + + /** + * Returns whether artifact cleanup completed successfully. + * + * @return true only for the terminal completed state + */ + public boolean isCompleted() { + return state == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED; + } + + /** + * Returns whether this receipt represents the same immutable request. + * + * @param other candidate receipt identity + * @return true when all immutable identity fields match + */ + public boolean hasSameIdentity(ArtifactDeletionReceipt other) { + return other != null + && requestId.equals(other.requestId) + && tenantId.equals(other.tenantId) + && jobId.equals(other.jobId) + && artifactChecksum.equals(other.artifactChecksum) + && auditCorrelationId.equals(other.auditCorrelationId) + && requestedAt.equals(other.requestedAt); + } + + ArtifactDeletionReceipt markMetadataTombstoned(Instant transitionedAt) { + requireState(ArtifactDeletionState.DELETION_REQUESTED); + return snapshot( + ArtifactDeletionState.METADATA_TOMBSTONED, + requireForwardTime(transitionedAt), + attemptCount, + lastAttemptAt, + null, + null + ); + } + + ArtifactDeletionReceipt markCleanupPending(Instant transitionedAt) { + if (state != ArtifactDeletionState.METADATA_TOMBSTONED + && state != ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED) { + throw invalidTransition(); + } + return snapshot( + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + requireForwardTime(transitionedAt), + attemptCount, + lastAttemptAt, + null, + null + ); + } + + ArtifactDeletionReceipt recordCleanupFailure(String controlledFailureCode, Instant attemptedAt) { + requireState(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING); + Instant requiredAttemptedAt = requireForwardTime(attemptedAt); + return snapshot( + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + requiredAttemptedAt, + Math.addExact(attemptCount, 1), + requiredAttemptedAt, + null, + requireFailureCode(controlledFailureCode) + ); + } + + ArtifactDeletionReceipt markCleanupCompleted(Instant completionInstant) { + requireState(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING); + Instant requiredCompletionInstant = requireForwardTime(completionInstant); + return snapshot( + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, + requiredCompletionInstant, + attemptCount, + lastAttemptAt, + requiredCompletionInstant, + null + ); + } + + private ArtifactDeletionReceipt snapshot( + ArtifactDeletionState nextState, + Instant nextStateChangedAt, + int nextAttemptCount, + Instant nextLastAttemptAt, + Instant nextCompletedAt, + String nextFailureCode + ) { + return new ArtifactDeletionReceipt( + requestId, + tenantId, + jobId, + artifactChecksum, + auditCorrelationId, + requestedAt, + nextStateChangedAt, + nextState, + nextAttemptCount, + nextLastAttemptAt, + nextCompletedAt, + nextFailureCode + ); + } + + private Instant requireForwardTime(Instant candidate) { + Instant requiredCandidate = Objects.requireNonNull(candidate, "transitionedAt"); + if (requiredCandidate.isBefore(stateChangedAt)) { + throw new IllegalArgumentException("stateChangedAt must not precede the prior transition"); + } + return requiredCandidate; + } + + private void requireState(ArtifactDeletionState requiredState) { + if (state != requiredState) { + throw invalidTransition(); + } + } + + private static void validateStateFields( + Instant currentRequestedAt, + Instant currentStateChangedAt, + ArtifactDeletionState currentState, + int currentAttemptCount, + Instant currentLastAttemptAt, + Instant currentCompletedAt, + String currentFailureCode + ) { + if (currentLastAttemptAt != null && currentLastAttemptAt.isBefore(currentRequestedAt)) { + throw new IllegalArgumentException("lastAttemptAt must not precede requestedAt"); + } + if (currentLastAttemptAt != null && currentLastAttemptAt.isAfter(currentStateChangedAt)) { + throw new IllegalArgumentException("lastAttemptAt must not follow stateChangedAt"); + } + boolean hasAttempts = currentAttemptCount > 0; + if (hasAttempts != (currentLastAttemptAt != null)) { + throw new IllegalArgumentException("cleanup attempt evidence is inconsistent"); + } + if (currentState == ArtifactDeletionState.DELETION_REQUESTED) { + if (!currentStateChangedAt.equals(currentRequestedAt) || hasAttempts) { + throw new IllegalArgumentException("requested receipt fields are inconsistent"); + } + } + if (currentState == ArtifactDeletionState.METADATA_TOMBSTONED && hasAttempts) { + throw new IllegalArgumentException("tombstoned receipt fields are inconsistent"); + } + if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED) { + if (currentCompletedAt == null + || !currentCompletedAt.equals(currentStateChangedAt) + || currentFailureCode != null) { + throw new IllegalArgumentException("completed receipt fields are inconsistent"); + } + return; + } + if (currentCompletedAt != null) { + throw new IllegalArgumentException("only completed receipts may have completedAt"); + } + if (currentState == ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED) { + if (!hasAttempts + || !currentLastAttemptAt.equals(currentStateChangedAt) + || currentFailureCode == null) { + throw new IllegalArgumentException("failed receipt fields are inconsistent"); + } + } else if (currentFailureCode != null) { + throw new IllegalArgumentException("only failed receipts may have failureCode"); + } + } + + private static String requireChecksum(String value) { + String normalized = requireText(value, "artifactChecksum"); + if (!SHA_256_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("artifactChecksum must be a lowercase SHA-256 digest"); + } + return normalized; + } + + private static String requireFailureCode(String value) { + String normalized = requireText(value, "controlledFailureCode"); + if (!FAILURE_CODE_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("failureCode must be a controlled code"); + } + return normalized; + } + + private static String requireText(String value, String fieldName) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + if (normalized.length() > MAX_IDENTIFIER_LENGTH) { + throw new IllegalArgumentException(fieldName + " exceeds the configured bound"); + } + return normalized; + } + + private static String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.isEmpty() ? null : normalized; + } + + private static IllegalStateException invalidTransition() { + return new IllegalStateException("artifact deletion receipt transition is invalid"); + } +} diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java new file mode 100644 index 00000000..bbf6fd4e --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptStore.java @@ -0,0 +1,100 @@ +package com.clearfolio.viewer.lifecycle; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Versioned persistence boundary for tenant-bound artifact-deletion receipts. + * + *

Standalone deployments may use the append-only reference ledger. Durable + * MSA adapters must preserve the same immutable request identity, monotonic + * state transitions, exact artifact digest, privacy-safe audit correlation, + * idempotency, and fail-closed conflict semantics.

+ */ +public interface ArtifactDeletionReceiptStore { + + /** + * Durably accepts one idempotent artifact-deletion request. + * + * @param requestId deletion idempotency identifier + * @param tenantId tenant that owns the conversion job + * @param jobId permanently reserved conversion-job identifier + * @param artifactChecksum lowercase SHA-256 artifact digest + * @param auditCorrelationId privacy-safe audit correlation identifier + * @param requestedAt instant when the request became durable + * @return new or previously accepted identical receipt + */ + ArtifactDeletionReceipt request( + UUID requestId, + String tenantId, + UUID jobId, + String artifactChecksum, + String auditCorrelationId, + Instant requestedAt + ); + + /** + * Records that tenant-owned job metadata has been tombstoned. + * + * @param jobId permanently reserved conversion-job identifier + * @param transitionedAt instant when the state became durable + * @return updated receipt + */ + ArtifactDeletionReceipt markMetadataTombstoned(UUID jobId, Instant transitionedAt); + + /** + * Marks exact-digest artifact cleanup ready for a worker attempt. + * + * @param jobId permanently reserved conversion-job identifier + * @param transitionedAt instant when the state became durable + * @return updated receipt + */ + ArtifactDeletionReceipt markCleanupPending(UUID jobId, Instant transitionedAt); + + /** + * Records one controlled cleanup failure while retaining retryable evidence. + * + * @param jobId permanently reserved conversion-job identifier + * @param failureCode controlled non-sensitive failure code + * @param attemptedAt failed attempt instant + * @return updated failed receipt + */ + ArtifactDeletionReceipt recordCleanupFailure( + UUID jobId, + String failureCode, + Instant attemptedAt + ); + + /** + * Records successful terminal cleanup of the exact artifact digest. + * + * @param jobId permanently reserved conversion-job identifier + * @param completedAt completion instant + * @return updated terminal receipt + */ + ArtifactDeletionReceipt markCleanupCompleted(UUID jobId, Instant completedAt); + + /** + * Finds the latest receipt for a job identifier. + * + * @param jobId conversion-job identifier + * @return receipt when present + */ + Optional findByJobId(UUID jobId); + + /** + * Returns all incomplete receipts in deterministic request order. + * + * @return pending and failed receipts + */ + List pendingReceipts(); + + /** + * Returns the number of incomplete receipts. + * + * @return pending and failed receipt count + */ + int pendingCount(); +} diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java new file mode 100644 index 00000000..de7287ad --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionState.java @@ -0,0 +1,31 @@ +package com.clearfolio.viewer.lifecycle; + +/** + * Durable states in the artifact-deletion receipt lifecycle. + */ +public enum ArtifactDeletionState { + /** + * An authorized deletion request has been durably accepted. + */ + DELETION_REQUESTED, + + /** + * Tenant-owned job metadata has been tombstoned. + */ + METADATA_TOMBSTONED, + + /** + * Exact-generation artifact cleanup is ready for a worker attempt. + */ + ARTIFACT_CLEANUP_PENDING, + + /** + * Artifact cleanup completed and the receipt is terminal. + */ + ARTIFACT_CLEANUP_COMPLETED, + + /** + * Artifact cleanup failed and remains eligible for a controlled retry. + */ + ARTIFACT_CLEANUP_FAILED +} diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index 74da3832..8b074888 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -238,10 +238,17 @@ public List findLifecycleEventsByJobId(UUID jobId) /** * Returns lifecycle events for a tenant. * + *

Missing or blank scoped tenant identifiers fail closed and never infer + * the explicit legacy demo tenant.

+ * * @param tenantId tenant identifier - * @return append-only lifecycle events for the tenant + * @return append-only lifecycle events for the tenant, or an empty list when + * scoped tenant context is absent */ public List findLifecycleEventsByTenantId(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + return List.of(); + } String normalizedTenantId = normalizeTenantId(tenantId); return lifecycleEvents.stream() .filter(event -> event.tenantId().equals(normalizedTenantId)) @@ -379,7 +386,7 @@ private String contentKey(String tenantId, String contentHash) { } private String normalizeTenantId(String tenantId) { - return tenantId == null || tenantId.isBlank() ? "buyer-demo" : tenantId.strip(); + return Objects.requireNonNull(tenantId, "tenantId").strip(); } private boolean matchesContentIndex(ConversionJob job, String expectedContentKey) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a7fe3844..9826263b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,11 @@ clearfolio: # restarts. in-memory: volatile store for tests and ephemeral demos. mode: filesystem root-dir: data/artifacts + artifact-deletion-ledger: + # Versioned append-only receipt snapshots are forced to storage before a + # lifecycle transition is reported as durable. Standalone tests can use the + # no-argument in-memory adapter; production keeps restart-replay evidence. + path: data/artifact-deletion-receipts.log viewer: security: diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java new file mode 100644 index 00000000..32163d00 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCoverageTest.java @@ -0,0 +1,372 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Exercises fail-closed receipt validation and append-only replay branches. + */ +class ArtifactDeletionLedgerCoverageTest { + + private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final String TENANT_ID = "tenant-north"; + private static final String CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String AUDIT_ID = "audit-v1:0123456789abcdef0123456789abcdef"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z"); + + @TempDir + Path tempDirectory; + + @Test + void stringConfigurationSupportsNullBlankAndFileBackedModes() { + ArtifactDeletionLedger nullConfigured = new ArtifactDeletionLedger((String) null); + ArtifactDeletionLedger blankConfigured = new ArtifactDeletionLedger(" "); + Path configuredPath = tempDirectory.resolve("configured_receipts.log"); + ArtifactDeletionLedger fileConfigured = new ArtifactDeletionLedger(configuredPath.toString()); + + request(nullConfigured, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + request(blankConfigured, UUID.randomUUID(), UUID.randomUUID(), TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + request(fileConfigured, UUID.randomUUID(), UUID.randomUUID(), TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + + assertFalse(nullConfigured.findByJobId(UUID.randomUUID()).isPresent()); + assertEquals(1, blankConfigured.pendingCount()); + assertTrue(Files.exists(configuredPath)); + } + + @Test + void pendingReceiptsAreSortedByRequestTimeAndJobIdentifier() { + ArtifactDeletionReceiptStore store = new ArtifactDeletionLedger(); + UUID laterJob = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); + UUID earlierJob = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID sameTimeEarlierJob = UUID.fromString("00000000-0000-0000-0000-000000000000"); + store.request(UUID.randomUUID(), TENANT_ID, laterJob, CHECKSUM, AUDIT_ID, REQUESTED_AT.plusSeconds(1)); + store.request(UUID.randomUUID(), TENANT_ID, earlierJob, CHECKSUM, AUDIT_ID, REQUESTED_AT); + store.request(UUID.randomUUID(), TENANT_ID, sameTimeEarlierJob, CHECKSUM, AUDIT_ID, REQUESTED_AT); + + assertEquals( + List.of(sameTimeEarlierJob, earlierJob, laterJob), + store.pendingReceipts().stream().map(ArtifactDeletionReceipt::jobId).toList() + ); + } + + @Test + void immutableIdentityComparisonRejectsEveryChangedField() { + ArtifactDeletionReceipt baseline = receipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + CHECKSUM, + AUDIT_ID, + REQUESTED_AT, + REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, + 0, + null, + null, + null + ); + + assertFalse(baseline.hasSameIdentity(null)); + assertFalse(baseline.hasSameIdentity(receipt(UUID.randomUUID(), TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, "tenant-south", JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, UUID.randomUUID(), CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID, + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, + "audit-v1:ffffffffffffffffffffffffffffffff", REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + assertFalse(baseline.hasSameIdentity(receipt(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT.plusSeconds(1), REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null))); + } + + @Test + void receiptConstructorRejectsInconsistentStateEvidence() { + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.minusSeconds(1), + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, + REQUESTED_AT.minusSeconds(1), null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.METADATA_TOMBSTONED, 0, + null, REQUESTED_AT, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0, + null, null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0, + null, REQUESTED_AT.plusSeconds(2), null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, 0, + null, REQUESTED_AT.plusSeconds(1), "storage_failed" + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 1, + null, null, "storage_failed" + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(2), + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 1, + REQUESTED_AT.plusSeconds(1), null, "storage_failed" + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT.plusSeconds(1), + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, 0, + REQUESTED_AT.plusSeconds(1), null, "storage_failed" + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.METADATA_TOMBSTONED, 0, + null, null, "storage_failed" + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, "t".repeat(257), JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, "a".repeat(257), + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + )); + assertThrows(IllegalArgumentException.class, () -> receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM.toUpperCase(), AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + )); + } + + @Test + void transitionMethodsRejectWrongStateAndNullTime() { + ArtifactDeletionReceipt requested = receipt( + REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, + REQUESTED_AT, REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, 0, null, null, null + ); + + assertThrows(NullPointerException.class, () -> requested.markMetadataTombstoned(null)); + assertThrows(IllegalStateException.class, () -> requested.markCleanupPending(REQUESTED_AT)); + assertThrows(IllegalStateException.class, () -> requested.recordCleanupFailure("storage_failed", REQUESTED_AT)); + assertThrows(IllegalStateException.class, () -> requested.markCleanupCompleted(REQUESTED_AT)); + assertEquals(REQUESTED_AT, requested.markMetadataTombstoned(REQUESTED_AT).stateChangedAt()); + } + + @Test + void strictUtf8CrLfAndBlankLineReplayAreHandledExplicitly() throws IOException { + Path source = tempDirectory.resolve("source.log"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(source); + request(ledger, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + String sourceText = Files.readString(source, StandardCharsets.UTF_8); + + Path crlf = tempDirectory.resolve("crlf.log"); + Files.writeString(crlf, sourceText.replace("\n", "\r\n"), StandardCharsets.UTF_8); + assertTrue(new ArtifactDeletionLedger(crlf).findByJobId(JOB_ID).isPresent()); + + Path blank = tempDirectory.resolve("blank.log"); + Files.writeString(blank, "\n", StandardCharsets.UTF_8); + assertEquals( + "artifact deletion ledger contains an invalid line", + assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(blank)).getMessage() + ); + + Path invalidUtf8 = tempDirectory.resolve("invalid_utf8.log"); + Files.write(invalidUtf8, new byte[] {(byte) 0xC3, 0x28, '\n'}); + assertEquals( + "artifact deletion ledger contains an invalid line", + assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(invalidUtf8)).getMessage() + ); + } + + @Test + void loadAndWriteIoFailuresAreReportedWithoutPartialSuccess() throws IOException { + Path directoryInsteadOfLedger = tempDirectory.resolve("ledger_directory"); + Files.createDirectory(directoryInsteadOfLedger); + assertEquals( + "artifact deletion ledger cannot be loaded", + assertThrows( + IllegalStateException.class, + () -> new ArtifactDeletionLedger(directoryInsteadOfLedger) + ).getMessage() + ); + + Path parentFile = tempDirectory.resolve("parent_file"); + Files.writeString(parentFile, "not a directory", StandardCharsets.UTF_8); + ArtifactDeletionLedger unwritable = new ArtifactDeletionLedger(parentFile.resolve("ledger.log")); + assertEquals( + "artifact deletion ledger cannot be written", + assertThrows( + IllegalStateException.class, + () -> request(unwritable, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT) + ).getMessage() + ); + assertTrue(unwritable.findByJobId(JOB_ID).isEmpty()); + } + + @Test + void replayRejectsInvalidFirstRecordIdentityAndTransitionSequences() throws IOException { + Path valid = tempDirectory.resolve("valid_sequence.log"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(valid); + request(ledger, JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(3)); + List lines = Files.readAllLines(valid, StandardCharsets.UTF_8); + + Path nonRequestedFirst = tempDirectory.resolve("non_requested_first.log"); + Files.write(nonRequestedFirst, lines.subList(1, 2), StandardCharsets.UTF_8); + assertInvalidLedger(nonRequestedFirst); + + Path nonZeroFirstAttempt = tempDirectory.resolve("nonzero_first_attempt.log"); + String[] requestedFields = fields(lines.getFirst()); + requestedFields[9] = "1"; + Files.writeString(nonZeroFirstAttempt, String.join("\t", requestedFields), StandardCharsets.UTF_8); + assertInvalidLedger(nonZeroFirstAttempt); + + Path completedThenPending = tempDirectory.resolve("completed_then_pending.log"); + List invalidTransitionLines = new ArrayList<>(lines); + invalidTransitionLines.add(lines.get(2)); + Files.write(completedThenPending, invalidTransitionLines, StandardCharsets.UTF_8); + assertInvalidLedger(completedThenPending); + + Path nonMonotonic = tempDirectory.resolve("non_monotonic.log"); + List nonMonotonicLines = new ArrayList<>(lines.subList(0, 3)); + String[] pendingFields = fields(nonMonotonicLines.get(2)); + pendingFields[7] = REQUESTED_AT.toString(); + nonMonotonicLines.set(2, String.join("\t", pendingFields)); + Files.write(nonMonotonic, nonMonotonicLines, StandardCharsets.UTF_8); + assertInvalidLedger(nonMonotonic); + } + + @Test + void replayRejectsIdentityConflictInvalidBase64AndMalformedRequiredFields() throws IOException { + Path north = tempDirectory.resolve("north.log"); + request(new ArtifactDeletionLedger(north), JOB_ID, REQUEST_ID, TENANT_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + Path south = tempDirectory.resolve("south.log"); + request( + new ArtifactDeletionLedger(south), + JOB_ID, + UUID.randomUUID(), + "tenant-south", + CHECKSUM, + "audit-v1:ffffffffffffffffffffffffffffffff", + REQUESTED_AT.plusSeconds(1) + ); + + Path conflict = tempDirectory.resolve("identity_conflict.log"); + List conflictLines = new ArrayList<>(Files.readAllLines(north, StandardCharsets.UTF_8)); + conflictLines.addAll(Files.readAllLines(south, StandardCharsets.UTF_8)); + Files.write(conflict, conflictLines, StandardCharsets.UTF_8); + assertInvalidLedger(conflict); + + String[] invalidBase64Fields = fields(Files.readAllLines(north, StandardCharsets.UTF_8).getFirst()); + invalidBase64Fields[2] = "*"; + Path invalidBase64 = tempDirectory.resolve("invalid_base64.log"); + Files.writeString(invalidBase64, String.join("\t", invalidBase64Fields), StandardCharsets.UTF_8); + assertInvalidLedger(invalidBase64); + + String[] absentTenantFields = fields(Files.readAllLines(north, StandardCharsets.UTF_8).getFirst()); + absentTenantFields[2] = "-"; + Path absentTenant = tempDirectory.resolve("absent_tenant.log"); + Files.writeString(absentTenant, String.join("\t", absentTenantFields), StandardCharsets.UTF_8); + assertInvalidLedger(absentTenant); + } + + private static void assertInvalidLedger(Path path) { + assertEquals( + "artifact deletion ledger contains an invalid line", + assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(path)).getMessage() + ); + } + + private static String[] fields(String line) { + return line.split("\t", -1); + } + + private static ArtifactDeletionReceipt request( + ArtifactDeletionReceiptStore store, + UUID jobId, + UUID requestId, + String tenantId, + String checksum, + String auditId, + Instant requestedAt + ) { + return store.request(requestId, tenantId, jobId, checksum, auditId, requestedAt); + } + + private static ArtifactDeletionReceipt receipt( + UUID requestId, + String tenantId, + UUID jobId, + String checksum, + String auditId, + Instant requestedAt, + Instant stateChangedAt, + ArtifactDeletionState state, + int attemptCount, + Instant lastAttemptAt, + Instant completedAt, + String failureCode + ) { + return new ArtifactDeletionReceipt( + requestId, + tenantId, + jobId, + checksum, + auditId, + requestedAt, + stateChangedAt, + state, + attemptCount, + lastAttemptAt, + completedAt, + failureCode + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java new file mode 100644 index 00000000..0dc349b7 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerCrashTailTest.java @@ -0,0 +1,75 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Arrays; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that restart replay never promotes an uncommitted ledger tail. + */ +class ArtifactDeletionLedgerCrashTailTest { + + private static final UUID REQUEST_ID = UUID.fromString("12121212-3434-5656-7878-909090909090"); + private static final UUID JOB_ID = UUID.fromString("abababab-cdcd-efef-1212-343434343434"); + private static final String CHECKSUM = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T09:00:00Z"); + + @TempDir + Path tempDirectory; + + @Test + void unterminatedFinalRecordFailsClosedAfterRestart() throws IOException { + Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log"); + ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath); + firstProcess.request( + REQUEST_ID, + "tenant-crash-recovery", + JOB_ID, + CHECKSUM, + "audit-v1:crash-tail-regression", + REQUESTED_AT + ); + + byte[] durableBytes = Files.readAllBytes(ledgerPath); + byte[] lineTerminator = {'\n'}; + assertTrue(endsWith(durableBytes, lineTerminator), "ledger append did not emit its commit delimiter"); + assertTrue( + durableBytes.length < 2 || durableBytes[durableBytes.length - 2] != '\r', + "ledger format must use host-independent LF rather than CRLF" + ); + Files.write( + ledgerPath, + Arrays.copyOf(durableBytes, durableBytes.length - lineTerminator.length) + ); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new ArtifactDeletionLedger(ledgerPath) + ); + + assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage()); + } + + private static boolean endsWith(byte[] value, byte[] suffix) { + if (value.length < suffix.length) { + return false; + } + int offset = value.length - suffix.length; + for (int index = 0; index < suffix.length; index++) { + if (value[offset + index] != suffix[index]) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java new file mode 100644 index 00000000..13a8e074 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayCoverageTest.java @@ -0,0 +1,158 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers receipt replay branches that protect immutable identity and monotonic + * retry evidence. + */ +class ArtifactDeletionLedgerReplayCoverageTest { + + private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final String TENANT_ID = "tenant-north"; + private static final String CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String AUDIT_ID = "audit-v1:0123456789abcdef0123456789abcdef"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z"); + + @TempDir + Path tempDirectory; + + @Test + void replayAcceptsCompleteFailureRetrySequenceAndRejectsWorkAfterCompletion() throws Exception { + Path path = tempDirectory.resolve("retry_sequence.log"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(path); + ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + ledger.recordCleanupFailure(JOB_ID, "artifact_store_delete_failed", REQUESTED_AT.plusSeconds(3)); + ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(4)); + ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(5)); + + ArtifactDeletionReceipt replayed = new ArtifactDeletionLedger(path) + .findByJobId(JOB_ID) + .orElseThrow(); + assertTrue(replayed.isCompleted()); + assertEquals(1, replayed.attemptCount()); + assertEquals(REQUESTED_AT.plusSeconds(3), replayed.lastAttemptAt()); + + List lines = new ArrayList<>(Files.readAllLines(path, StandardCharsets.UTF_8)); + lines.add(lines.get(2)); + Files.write(path, lines, StandardCharsets.UTF_8); + + assertInvalidLedger(path); + } + + @Test + void replayRejectsWrongRecordVersionAndBlankRequiredTenant() throws Exception { + Path source = tempDirectory.resolve("source.log"); + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(source); + ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + String[] validFields = Files.readString(source, StandardCharsets.UTF_8) + .stripTrailing() + .split("\\t", -1); + + String[] wrongVersionFields = validFields.clone(); + wrongVersionFields[0] = "RECEIPT_V2"; + Path wrongVersion = tempDirectory.resolve("wrong_version.log"); + Files.writeString( + wrongVersion, + String.join("\t", wrongVersionFields) + "\n", + StandardCharsets.UTF_8 + ); + assertInvalidLedger(wrongVersion); + + String[] blankTenantFields = validFields.clone(); + blankTenantFields[2] = ""; + Path blankTenant = tempDirectory.resolve("blank_tenant.log"); + Files.writeString( + blankTenant, + String.join("\t", blankTenantFields) + "\n", + StandardCharsets.UTF_8 + ); + assertInvalidLedger(blankTenant); + } + + @Test + void failedReceiptRejectsLastAttemptTimeThatDiffersFromStateTime() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + CHECKSUM, + AUDIT_ID, + REQUESTED_AT, + REQUESTED_AT.plusSeconds(3), + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + 1, + REQUESTED_AT.plusSeconds(2), + null, + "artifact_store_delete_failed" + ) + ); + + assertEquals("Failed receipt has inconsistent failure evidence.", exception.getMessage()); + } + + @Test + void transitionRejectsAChangedImmutableIdentity() throws Exception { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ledger.request(REQUEST_ID, TENANT_ID, JOB_ID, CHECKSUM, AUDIT_ID, REQUESTED_AT); + Method transition = ArtifactDeletionLedger.class.getDeclaredMethod( + "transition", + UUID.class, + UnaryOperator.class + ); + transition.setAccessible(true); + UnaryOperator changeTenant = current -> new ArtifactDeletionReceipt( + current.requestId(), + "tenant-south", + current.jobId(), + current.artifactChecksum(), + current.auditCorrelationId(), + current.requestedAt(), + current.stateChangedAt(), + current.state(), + current.attemptCount(), + current.lastAttemptAt(), + current.completedAt(), + current.failureCode() + ); + + InvocationTargetException invocation = assertThrows( + InvocationTargetException.class, + () -> transition.invoke(ledger, JOB_ID, changeTenant) + ); + + assertEquals( + "Artifact deletion receipt transition changed immutable identity.", + invocation.getCause().getMessage() + ); + assertEquals(TENANT_ID, ledger.findByJobId(JOB_ID).orElseThrow().tenantId()); + } + + private static void assertInvalidLedger(Path path) { + assertEquals( + "artifact deletion ledger contains an invalid line", + assertThrows(IllegalStateException.class, () -> new ArtifactDeletionLedger(path)).getMessage() + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java new file mode 100644 index 00000000..af23162a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerReplayValidationTest.java @@ -0,0 +1,381 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that restart replay accepts only complete, internally consistent + * deletion-lifecycle evidence. + */ +class ArtifactDeletionLedgerReplayValidationTest { + + private static final UUID REQUEST_ID = UUID.fromString("23232323-4545-6767-8989-010101010101"); + private static final UUID JOB_ID = UUID.fromString("bcbcbcbc-dede-fafa-2323-454545454545"); + private static final String TENANT_ID = "tenant-replay-validation"; + private static final String CHECKSUM = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + private static final String AUDIT_ID = "audit-v1:replay-validation"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T10:00:00Z"); + + @TempDir + Path tempDirectory; + + @Test + void invalidUtf8LedgerBytesFailClosed() throws IOException { + Path ledgerPath = ledgerPath(); + Files.write(ledgerPath, new byte[] {(byte) 0xc3, 0x28, '\n'}); + + assertInvalidLedger(ledgerPath); + } + + @Test + void whitespaceOnlyRequiredFieldFailsClosed() throws IOException { + Path ledgerPath = ledgerPath(); + Files.writeString( + ledgerPath, + line( + " ", + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + null, + null, + null + ), + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void immutableIdentityConflictFailsClosed() throws IOException { + Path ledgerPath = ledgerPath(); + String requested = line( + TENANT_ID, + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + null, + null, + null + ); + String conflicting = line( + "tenant-conflicting-owner", + ArtifactDeletionState.METADATA_TOMBSTONED, + REQUESTED_AT.plusSeconds(1), + 0, + null, + null, + null + ); + Files.writeString(ledgerPath, requested + conflicting, StandardCharsets.UTF_8); + + assertInvalidLedger(ledgerPath); + } + + @Test + void nonMonotonicTransitionTimeFailsClosed() throws IOException { + Path ledgerPath = ledgerPath(); + String requested = line( + TENANT_ID, + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + null, + null, + null + ); + String tombstoned = line( + TENANT_ID, + ArtifactDeletionState.METADATA_TOMBSTONED, + REQUESTED_AT.plusSeconds(3), + 0, + null, + null, + null + ); + String pendingEarlier = line( + TENANT_ID, + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + REQUESTED_AT.plusSeconds(2), + 0, + null, + null, + null + ); + Files.writeString( + ledgerPath, + requested + tombstoned + pendingEarlier, + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void illegalSubsequentStateFailsClosed() throws IOException { + Path ledgerPath = ledgerPath(); + String requested = line( + TENANT_ID, + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + null, + null, + null + ); + String pendingWithoutTombstone = line( + TENANT_ID, + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + REQUESTED_AT.plusSeconds(1), + 0, + null, + null, + null + ); + Files.writeString( + ledgerPath, + requested + pendingWithoutTombstone, + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void stateSnapshotsRejectImpossibleAttemptEvidence() { + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 1, + REQUESTED_AT, + null, + null + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionState.METADATA_TOMBSTONED, + REQUESTED_AT.plusSeconds(1), + 1, + REQUESTED_AT, + null, + null + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + REQUESTED_AT.plusSeconds(2), + 1, + REQUESTED_AT.plusSeconds(3), + null, + null + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt( + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + REQUESTED_AT.plusSeconds(2), + 0, + null, + null, + "storage_timeout" + ) + ); + } + + @Test + void requestedStateCannotPairZeroAttemptsWithAttemptTime() throws IOException { + Path ledgerPath = ledgerPath(); + Files.writeString( + ledgerPath, + line( + TENANT_ID, + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + REQUESTED_AT, + null, + null + ), + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void retriedPendingStateMustPreserveLastAttemptEvidence() throws IOException { + Path ledgerPath = ledgerPath(); + Instant tombstonedAt = REQUESTED_AT.plusSeconds(1); + Instant firstPendingAt = REQUESTED_AT.plusSeconds(2); + Instant failedAt = REQUESTED_AT.plusSeconds(3); + Instant retryAt = REQUESTED_AT.plusSeconds(4); + String requested = line( + TENANT_ID, + ArtifactDeletionState.DELETION_REQUESTED, + REQUESTED_AT, + 0, + null, + null, + null + ); + String tombstoned = line( + TENANT_ID, + ArtifactDeletionState.METADATA_TOMBSTONED, + tombstonedAt, + 0, + null, + null, + null + ); + String pending = line( + TENANT_ID, + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + firstPendingAt, + 0, + null, + null, + null + ); + String failed = line( + TENANT_ID, + ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, + failedAt, + 1, + failedAt, + null, + "storage_timeout" + ); + String retryWithRewrittenAttemptTime = line( + TENANT_ID, + ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, + retryAt, + 1, + firstPendingAt, + null, + null + ); + Files.writeString( + ledgerPath, + requested + tombstoned + pending + failed + retryWithRewrittenAttemptTime, + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + @Test + void retriedPendingStateMustPreserveAttemptCount() throws IOException { + Path ledgerPath = ledgerPath(); + Instant tombstonedAt = REQUESTED_AT.plusSeconds(1); + Instant pendingAt = REQUESTED_AT.plusSeconds(2); + Instant failedAt = REQUESTED_AT.plusSeconds(3); + Instant retryAt = REQUESTED_AT.plusSeconds(4); + Files.writeString( + ledgerPath, + line(TENANT_ID, ArtifactDeletionState.DELETION_REQUESTED, REQUESTED_AT, 0, null, null, null) + + line(TENANT_ID, ArtifactDeletionState.METADATA_TOMBSTONED, tombstonedAt, + 0, null, null, null) + + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, pendingAt, + 0, null, null, null) + + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, failedAt, + 1, failedAt, null, "storage_timeout") + + line(TENANT_ID, ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, retryAt, + 2, failedAt, null, null), + StandardCharsets.UTF_8 + ); + + assertInvalidLedger(ledgerPath); + } + + private Path ledgerPath() { + return tempDirectory.resolve("artifact_deletion_receipt.log"); + } + + private static void assertInvalidLedger(Path ledgerPath) { + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new ArtifactDeletionLedger(ledgerPath) + ); + assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage()); + } + + private static ArtifactDeletionReceipt receipt( + ArtifactDeletionState state, + Instant stateChangedAt, + int attemptCount, + Instant lastAttemptAt, + Instant completedAt, + String failureCode + ) { + return new ArtifactDeletionReceipt( + REQUEST_ID, + TENANT_ID, + JOB_ID, + CHECKSUM, + AUDIT_ID, + REQUESTED_AT, + stateChangedAt, + state, + attemptCount, + lastAttemptAt, + completedAt, + failureCode + ); + } + + private static String line( + String tenantId, + ArtifactDeletionState state, + Instant stateChangedAt, + int attemptCount, + Instant lastAttemptAt, + Instant completedAt, + String failureCode + ) { + return String.join( + "\t", + "RECEIPT_V1", + REQUEST_ID.toString(), + encode(tenantId), + JOB_ID.toString(), + encode(CHECKSUM), + encode(AUDIT_ID), + REQUESTED_AT.toString(), + stateChangedAt.toString(), + state.name(), + Integer.toString(attemptCount), + optionalInstant(lastAttemptAt), + optionalInstant(completedAt), + failureCode == null ? "-" : encode(failureCode) + ) + "\n"; + } + + private static String encode(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String optionalInstant(Instant value) { + return value == null ? "-" : value.toString(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java new file mode 100644 index 00000000..e4e77082 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionLedgerTest.java @@ -0,0 +1,317 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies durable, idempotent, monotonic artifact-deletion receipt evidence. + */ +class ArtifactDeletionLedgerTest { + + private static final UUID REQUEST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final UUID JOB_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + private static final String TENANT_ID = "tenant-north"; + private static final String ARTIFACT_CHECKSUM = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final String AUDIT_CORRELATION_ID = "audit-v1:0123456789abcdef0123456789abcdef"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-06T00:00:00Z"); + + @TempDir + Path tempDirectory; + + @Test + void createsAndFindsRequestedReceipt() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + + ArtifactDeletionReceipt receipt = request(ledger); + + assertEquals(REQUEST_ID, receipt.requestId()); + assertEquals(TENANT_ID, receipt.tenantId()); + assertEquals(JOB_ID, receipt.jobId()); + assertEquals(ARTIFACT_CHECKSUM, receipt.artifactChecksum()); + assertEquals(AUDIT_CORRELATION_ID, receipt.auditCorrelationId()); + assertEquals(REQUESTED_AT, receipt.requestedAt()); + assertEquals(REQUESTED_AT, receipt.stateChangedAt()); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, receipt.state()); + assertEquals(0, receipt.attemptCount()); + assertTrue(ledger.findByJobId(JOB_ID).isPresent()); + assertEquals(1, ledger.pendingCount()); + } + + @Test + void identicalDeletionRequestIsIdempotent() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + ArtifactDeletionReceipt original = request(ledger); + + ArtifactDeletionReceipt repeated = request(ledger); + + assertSame(original, repeated); + assertEquals(1, ledger.pendingCount()); + } + + @Test + void conflictingRequestForReservedJobFailsClosed() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + request(ledger); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> ledger.request( + UUID.randomUUID(), + "tenant-south", + JOB_ID, + ARTIFACT_CHECKSUM, + "audit-v1:ffffffffffffffffffffffffffffffff", + REQUESTED_AT.plusSeconds(1) + ) + ); + + assertEquals("artifact deletion receipt conflicts with an existing lifecycle", exception.getMessage()); + assertEquals(TENANT_ID, ledger.findByJobId(JOB_ID).orElseThrow().tenantId()); + } + + @Test + void stateTransitionsAreMonotonicAndCompletionIsTerminal() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + request(ledger); + + ArtifactDeletionReceipt tombstoned = ledger.markMetadataTombstoned( + JOB_ID, + REQUESTED_AT.plusSeconds(1) + ); + ArtifactDeletionReceipt pending = ledger.markCleanupPending( + JOB_ID, + REQUESTED_AT.plusSeconds(2) + ); + ArtifactDeletionReceipt completed = ledger.markCleanupCompleted( + JOB_ID, + REQUESTED_AT.plusSeconds(3) + ); + + assertEquals(ArtifactDeletionState.METADATA_TOMBSTONED, tombstoned.state()); + assertEquals(REQUESTED_AT.plusSeconds(1), tombstoned.stateChangedAt()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, pending.state()); + assertEquals(REQUESTED_AT.plusSeconds(2), pending.stateChangedAt()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_COMPLETED, completed.state()); + assertEquals(REQUESTED_AT.plusSeconds(3), completed.stateChangedAt()); + assertEquals(REQUESTED_AT.plusSeconds(3), completed.completedAt()); + assertEquals(0, ledger.pendingCount()); + assertThrows( + IllegalStateException.class, + () -> ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(4)) + ); + } + + @Test + void transitionTimeCannotMoveBackward() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + request(ledger); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.minusSeconds(1)) + ); + + assertEquals("stateChangedAt must not precede the prior transition", exception.getMessage()); + assertEquals(ArtifactDeletionState.DELETION_REQUESTED, ledger.findByJobId(JOB_ID).orElseThrow().state()); + } + + @Test + void failedCleanupCanBeRetriedAndAttemptsRemainDurable() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + request(ledger); + ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + + ArtifactDeletionReceipt firstFailure = ledger.recordCleanupFailure( + JOB_ID, + "storage_unavailable", + REQUESTED_AT.plusSeconds(3) + ); + ArtifactDeletionReceipt retry = ledger.markCleanupPending( + JOB_ID, + REQUESTED_AT.plusSeconds(4) + ); + ArtifactDeletionReceipt secondFailure = ledger.recordCleanupFailure( + JOB_ID, + "storage_unavailable", + REQUESTED_AT.plusSeconds(5) + ); + + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, firstFailure.state()); + assertEquals(1, firstFailure.attemptCount()); + assertEquals("storage_unavailable", firstFailure.failureCode()); + assertEquals(REQUESTED_AT.plusSeconds(3), firstFailure.stateChangedAt()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, retry.state()); + assertEquals(1, retry.attemptCount()); + assertEquals(REQUESTED_AT.plusSeconds(4), retry.stateChangedAt()); + assertEquals(2, secondFailure.attemptCount()); + assertEquals(REQUESTED_AT.plusSeconds(5), secondFailure.lastAttemptAt()); + assertEquals(REQUESTED_AT.plusSeconds(5), secondFailure.stateChangedAt()); + } + + @Test + void failureCodeMustBeControlledAndCannotCarryStorageDetails() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + request(ledger); + ledger.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + ledger.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ledger.recordCleanupFailure( + JOB_ID, + "permission denied for /private/artifacts/" + JOB_ID + ".pdf", + REQUESTED_AT.plusSeconds(3) + ) + ); + + assertEquals("failureCode must be a controlled code", exception.getMessage()); + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_PENDING, ledger.findByJobId(JOB_ID).orElseThrow().state()); + } + + @Test + void fileBackedLedgerReplaysTheLatestReceiptAfterRestart() { + Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log"); + ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath); + request(firstProcess); + firstProcess.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + firstProcess.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + firstProcess.recordCleanupFailure(JOB_ID, "storage_timeout", REQUESTED_AT.plusSeconds(3)); + + ArtifactDeletionLedger restartedProcess = new ArtifactDeletionLedger(ledgerPath); + ArtifactDeletionReceipt restored = restartedProcess.findByJobId(JOB_ID).orElseThrow(); + + assertEquals(ArtifactDeletionState.ARTIFACT_CLEANUP_FAILED, restored.state()); + assertEquals(1, restored.attemptCount()); + assertEquals("storage_timeout", restored.failureCode()); + assertEquals(REQUESTED_AT.plusSeconds(3), restored.stateChangedAt()); + assertEquals(AUDIT_CORRELATION_ID, restored.auditCorrelationId()); + assertEquals(List.of(restored), restartedProcess.pendingReceipts()); + assertSame(restored, request(restartedProcess)); + } + + @Test + void completedReceiptsAreNotReturnedAsPendingAfterRestart() { + Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log"); + ArtifactDeletionLedger firstProcess = new ArtifactDeletionLedger(ledgerPath); + request(firstProcess); + firstProcess.markMetadataTombstoned(JOB_ID, REQUESTED_AT.plusSeconds(1)); + firstProcess.markCleanupPending(JOB_ID, REQUESTED_AT.plusSeconds(2)); + firstProcess.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(3)); + + ArtifactDeletionLedger restartedProcess = new ArtifactDeletionLedger(ledgerPath); + + assertTrue(restartedProcess.pendingReceipts().isEmpty()); + assertEquals(0, restartedProcess.pendingCount()); + assertTrue(restartedProcess.findByJobId(JOB_ID).orElseThrow().isCompleted()); + } + + @Test + void malformedLedgerInputFailsClosed() throws IOException { + Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log"); + Files.writeString(ledgerPath, "not-a-valid-ledger-line\n", StandardCharsets.UTF_8); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new ArtifactDeletionLedger(ledgerPath) + ); + + assertEquals("artifact deletion ledger contains an invalid line", exception.getMessage()); + } + + @Test + void oversizedLedgerLineFailsClosed() throws IOException { + Path ledgerPath = tempDirectory.resolve("artifact_deletion_receipt.log"); + Files.writeString(ledgerPath, "x".repeat(ArtifactDeletionLedger.MAX_LEDGER_LINE_BYTES + 1), StandardCharsets.UTF_8); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new ArtifactDeletionLedger(ledgerPath) + ); + + assertEquals("artifact deletion ledger line exceeds the configured bound", exception.getMessage()); + } + + @Test + void missingReceiptAndInvalidTransitionFailClosed() { + ArtifactDeletionLedger ledger = new ArtifactDeletionLedger(); + + assertTrue(ledger.findByJobId(null).isEmpty()); + assertThrows( + IllegalStateException.class, + () -> ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT) + ); + + request(ledger); + assertThrows( + IllegalStateException.class, + () -> ledger.markCleanupCompleted(JOB_ID, REQUESTED_AT.plusSeconds(1)) + ); + } + + @Test + void receiptValidationRejectsInvalidIdentityChecksumAndAttempts() { + assertThrows( + IllegalArgumentException.class, + () -> receipt(" ", ARTIFACT_CHECKSUM, AUDIT_CORRELATION_ID, 0) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt(TENANT_ID, "not-a-sha256", AUDIT_CORRELATION_ID, 0) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt(TENANT_ID, ARTIFACT_CHECKSUM, " ", 0) + ); + assertThrows( + IllegalArgumentException.class, + () -> receipt(TENANT_ID, ARTIFACT_CHECKSUM, AUDIT_CORRELATION_ID, -1) + ); + } + + private static ArtifactDeletionReceipt receipt( + String tenantId, + String artifactChecksum, + String auditCorrelationId, + int attemptCount + ) { + return new ArtifactDeletionReceipt( + REQUEST_ID, + tenantId, + JOB_ID, + artifactChecksum, + auditCorrelationId, + REQUESTED_AT, + REQUESTED_AT, + ArtifactDeletionState.DELETION_REQUESTED, + attemptCount, + null, + null, + null + ); + } + + private static ArtifactDeletionReceipt request(ArtifactDeletionLedger ledger) { + return ledger.request( + REQUEST_ID, + TENANT_ID, + JOB_ID, + ARTIFACT_CHECKSUM, + AUDIT_CORRELATION_ID, + REQUESTED_AT + ); + } +} diff --git a/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java new file mode 100644 index 00000000..7a0c9828 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepositoryTenantLifecycleIsolationTest.java @@ -0,0 +1,37 @@ +package com.clearfolio.viewer.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.model.ConversionJob; + +/** + * Verifies that lifecycle-event queries never infer the demo tenant from a + * missing scoped tenant identifier. + */ +class InMemoryConversionJobRepositoryTenantLifecycleIsolationTest { + + @Test + void nullAndBlankTenantQueriesCannotReadDemoLifecycleEvents() { + InMemoryConversionJobRepository repository = new InMemoryConversionJobRepository(); + ConversionJob demoJob = new ConversionJob( + UUID.fromString("11111111-2222-3333-4444-555555555555"), + "buyer-demo", + "subject-demo", + "report.pdf", + "application/pdf", + "tenant-lifecycle-isolation-hash", + 42L, + 3 + ); + repository.findOrStoreByContentHash(demoJob); + + assertEquals(1, repository.findLifecycleEventsByTenantId("buyer-demo").size()); + assertTrue(repository.findLifecycleEventsByTenantId(null).isEmpty()); + assertTrue(repository.findLifecycleEventsByTenantId(" ").isEmpty()); + } +}