diff --git a/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentity.java b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentity.java new file mode 100644 index 000000000..0348cec46 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentity.java @@ -0,0 +1,85 @@ +package com.clearfolio.viewer.lifecycle; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Immutable authority binding for one durable artifact-deletion request. + * + *

The value object deliberately contains no storage path, filename, token, + * document bytes, or raw subject identifier. Its artifact checksum is an exact + * lowercase SHA-256 digest so later deletion-receipt work can bind cleanup to + * one previously observed artifact generation without accepting an ambiguous + * textual representation.

+ * + * @param requestId immutable idempotency identifier for the deletion request + * @param tenantId server-authoritative tenant that owns the conversion job + * @param jobId permanently reserved conversion-job identifier + * @param artifactChecksum lowercase SHA-256 digest of the bound artifact bytes + * @param auditCorrelationId privacy-safe correlation identifier for lifecycle evidence + * @param requestedAt instant when the deletion request identity became authoritative + */ +public record ArtifactDeletionReceiptIdentity( + UUID requestId, + String tenantId, + UUID jobId, + String artifactChecksum, + String auditCorrelationId, + Instant requestedAt +) { + + private static final int MAX_IDENTIFIER_LENGTH = 256; + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + /** + * Validates and normalizes one immutable deletion-receipt identity. + * + * @throws NullPointerException when an immutable identifier or timestamp is absent + * @throws IllegalArgumentException when bounded text is blank, control-corrupted, + * oversized, or the artifact digest is not canonical SHA-256 + */ + public ArtifactDeletionReceiptIdentity { + requestId = Objects.requireNonNull(requestId, "requestId"); + tenantId = requireBoundedText(tenantId, "tenantId"); + jobId = Objects.requireNonNull(jobId, "jobId"); + artifactChecksum = requireSha256(artifactChecksum); + auditCorrelationId = requireBoundedText(auditCorrelationId, "auditCorrelationId"); + requestedAt = Objects.requireNonNull(requestedAt, "requestedAt"); + } + + private static String requireSha256(String value) { + String checksum = Objects.requireNonNull(value, "artifactChecksum"); + if (checksum.isBlank()) { + throw new IllegalArgumentException("artifactChecksum must not be blank"); + } + if (!SHA_256_PATTERN.matcher(checksum).matches()) { + throw new IllegalArgumentException( + "artifactChecksum must be a lowercase SHA-256 digest"); + } + return checksum; + } + + private static String requireBoundedText(String value, String fieldName) { + String supplied = Objects.requireNonNull(value, fieldName); + if (supplied.codePoints().anyMatch(ArtifactDeletionReceiptIdentity::isDisallowedIdentifierCharacter)) { + throw new IllegalArgumentException(fieldName + " must not contain control characters"); + } + String normalized = supplied.strip(); + if (normalized.isEmpty()) { + 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 boolean isDisallowedIdentifierCharacter(int codePoint) { + int characterType = Character.getType(codePoint); + return Character.isISOControl(codePoint) + || characterType == Character.LINE_SEPARATOR + || characterType == Character.PARAGRAPH_SEPARATOR; + } +} diff --git a/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentityTest.java b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentityTest.java new file mode 100644 index 000000000..0e3813d9a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/lifecycle/ArtifactDeletionReceiptIdentityTest.java @@ -0,0 +1,175 @@ +package com.clearfolio.viewer.lifecycle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Defines the immutable, tenant-bound identity carried by future durable deletion receipts. + */ +class ArtifactDeletionReceiptIdentityTest { + + 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 CHECKSUM = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final Instant REQUESTED_AT = Instant.parse("2026-08-11T00:00:00Z"); + + @Test + void normalizesBoundedTextWithoutChangingCryptographicDigest() { + ArtifactDeletionReceiptIdentity identity = new ArtifactDeletionReceiptIdentity( + REQUEST_ID, + " tenant-example ", + JOB_ID, + CHECKSUM, + " audit-v1:0123456789abcdef ", + REQUESTED_AT + ); + + assertEquals(REQUEST_ID, identity.requestId()); + assertEquals("tenant-example", identity.tenantId()); + assertEquals(JOB_ID, identity.jobId()); + assertEquals(CHECKSUM, identity.artifactChecksum()); + assertEquals("audit-v1:0123456789abcdef", identity.auditCorrelationId()); + assertEquals(REQUESTED_AT, identity.requestedAt()); + } + + @Test + void rejectsMissingImmutableIdentifiersAndTime() { + assertThrows( + NullPointerException.class, + () -> new ArtifactDeletionReceiptIdentity( + null, "tenant-example", JOB_ID, CHECKSUM, "audit-v1:abc", REQUESTED_AT)); + assertThrows( + NullPointerException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", null, CHECKSUM, "audit-v1:abc", REQUESTED_AT)); + assertThrows( + NullPointerException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, CHECKSUM, "audit-v1:abc", null)); + } + + @Test + void rejectsBlankOrOversizedTenantAndAuditIdentifiers() { + assertEquals( + "tenantId must not contain control characters", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, " \t ", JOB_ID, CHECKSUM, "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "auditCorrelationId must not be blank", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, CHECKSUM, " ", REQUESTED_AT)) + .getMessage()); + assertEquals( + "tenantId exceeds the configured bound", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "t".repeat(257), JOB_ID, CHECKSUM, "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "auditCorrelationId exceeds the configured bound", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, CHECKSUM, "a".repeat(257), REQUESTED_AT)) + .getMessage()); + } + + @Test + void rejectsControlCorruptedTenantAndAuditIdentifiers() { + for (String tenantId : List.of( + "tenant\u0000-a", + "tenant\n-a", + "tenant\u001B-a", + "tenant\u2028-a" + )) { + assertEquals( + "tenantId must not contain control characters", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, + tenantId, + JOB_ID, + CHECKSUM, + "audit-v1:abc", + REQUESTED_AT + ) + ).getMessage() + ); + } + + for (String auditCorrelationId : List.of( + "audit\u0000-v1", + "audit\r-v1", + "audit\u007F-v1", + "audit\u2029-v1" + )) { + assertEquals( + "auditCorrelationId must not contain control characters", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, + "tenant-example", + JOB_ID, + CHECKSUM, + auditCorrelationId, + REQUESTED_AT + ) + ).getMessage() + ); + } + } + + @Test + void rejectsMissingOrNonCanonicalArtifactDigest() { + assertEquals( + "artifactChecksum must not be blank", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, " ", "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "artifactChecksum must be a lowercase SHA-256 digest", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, CHECKSUM.toUpperCase(), "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "artifactChecksum must be a lowercase SHA-256 digest", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, "0".repeat(63), "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "artifactChecksum must be a lowercase SHA-256 digest", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, " " + CHECKSUM, "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + assertEquals( + "artifactChecksum must be a lowercase SHA-256 digest", + assertThrows( + IllegalArgumentException.class, + () -> new ArtifactDeletionReceiptIdentity( + REQUEST_ID, "tenant-example", JOB_ID, CHECKSUM + " ", "audit-v1:abc", REQUESTED_AT)) + .getMessage()); + } +}