Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* @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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading