diff --git a/src/main/java/com/clearfolio/viewer/model/ConversionJob.java b/src/main/java/com/clearfolio/viewer/model/ConversionJob.java index c5b553ba..1a9cb7a5 100644 --- a/src/main/java/com/clearfolio/viewer/model/ConversionJob.java +++ b/src/main/java/com/clearfolio/viewer/model/ConversionJob.java @@ -82,7 +82,12 @@ public ConversionJob( } /** - * Creates a conversion job with tenant and subject ownership metadata. + * Creates a conversion job with explicit tenant and subject ownership metadata. + * + *
This authority-bearing constructor fails closed when tenant or subject + * claims are absent or control-corrupted. Development callers that + * intentionally use demo authority must use one of the convenience + * constructors that supplies the demo identities explicitly.
* * @param jobId job identifier * @param tenantId tenant isolation boundary @@ -104,8 +109,8 @@ public ConversionJob( int maxAttempts ) { this.jobId = jobId; - this.tenantId = normalizeOrDefault(tenantId, DEFAULT_TENANT_ID); - this.subjectId = normalizeOrDefault(subjectId, DEFAULT_SUBJECT_ID); + this.tenantId = requireAuthority(tenantId, "tenantId"); + this.subjectId = requireAuthority(subjectId, "subjectId"); this.originalFileName = sanitize(originalFileName); this.contentType = sanitize(contentType); this.contentHash = contentHash; @@ -125,6 +130,27 @@ private String sanitize(String value) { return value.replace("\u0000", ""); } + private String requireAuthority(String value, String fieldName) { + if (value == null) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + if (value.codePoints().anyMatch(ConversionJob::isDisallowedAuthorityCharacter)) { + throw new IllegalArgumentException(fieldName + " must not contain control characters"); + } + String normalized = value.strip(); + if (normalized.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return normalized; + } + + private static boolean isDisallowedAuthorityCharacter(int codePoint) { + int characterType = Character.getType(codePoint); + return Character.isISOControl(codePoint) + || characterType == Character.LINE_SEPARATOR + || characterType == Character.PARAGRAPH_SEPARATOR; + } + private String normalizeOrDefault(String value, String fallback) { String sanitized = sanitize(value); if (sanitized == null || sanitized.isBlank()) { diff --git a/src/test/java/com/clearfolio/viewer/model/ConversionJobTenantAuthorityTest.java b/src/test/java/com/clearfolio/viewer/model/ConversionJobTenantAuthorityTest.java new file mode 100644 index 00000000..cce4ab1b --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/model/ConversionJobTenantAuthorityTest.java @@ -0,0 +1,135 @@ +package com.clearfolio.viewer.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +/** + * Proves that the tenant-aware conversion-job constructor never manufactures + * or normalizes unsafe production authority when explicit claims are absent or + * control-corrupted. + */ +class ConversionJobTenantAuthorityTest { + + @Test + void explicitConstructorRejectsMissingTenantAuthority() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + null, + "subject-a", + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("tenantId must not be blank", exception.getMessage()); + } + + @Test + void explicitConstructorRejectsBlankTenantAuthorityAfterSanitization() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + " \u0000 ", + "subject-a", + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("tenantId must not contain control characters", exception.getMessage()); + } + + @Test + void explicitConstructorRejectsControlCorruptedTenantAuthority() { + for (String tenantId : List.of("tenant\u0000-a", "tenant\n-a", "tenant\u001B-a")) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + tenantId, + "subject-a", + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("tenantId must not contain control characters", exception.getMessage()); + } + } + + @Test + void explicitConstructorRejectsMissingSubjectAuthority() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + "tenant-a", + null, + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("subjectId must not be blank", exception.getMessage()); + } + + @Test + void explicitConstructorRejectsBlankSubjectAuthorityAfterSanitization() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + "tenant-a", + " \u0000 ", + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("subjectId must not contain control characters", exception.getMessage()); + } + + @Test + void explicitConstructorRejectsControlCorruptedSubjectAuthority() { + for (String subjectId : List.of("subject\u0000-a", "subject\t-a", "subject\u007F-a")) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new ConversionJob( + UUID.randomUUID(), + "tenant-a", + subjectId, + "report.pdf", + "application/pdf", + "hash", + 10L, + 3 + ) + ); + + assertEquals("subjectId must not contain control characters", exception.getMessage()); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/model/ConversionJobTest.java b/src/test/java/com/clearfolio/viewer/model/ConversionJobTest.java index dafeb708..44d2ecd1 100644 --- a/src/test/java/com/clearfolio/viewer/model/ConversionJobTest.java +++ b/src/test/java/com/clearfolio/viewer/model/ConversionJobTest.java @@ -55,6 +55,8 @@ void constructorSetsDefaultDemoTenantMetadata() { assertEquals(TenantContext.DEMO_TENANT_ID, job.getTenantId()); assertEquals(TenantContext.DEMO_SUBJECT_ID, job.getSubjectId()); assertTrue(job.belongsToTenant(TenantContext.DEMO_TENANT_ID)); + assertFalse(job.belongsToTenant(null)); + assertFalse(job.belongsToTenant(" \u0000 ")); } @Test @@ -76,24 +78,6 @@ void constructorAcceptsExplicitTenantMetadata() { assertFalse(job.belongsToTenant("tenant-b")); } - @Test - void constructorFallsBackToDemoMetadataForBlankTenantClaims() { - ConversionJob job = new ConversionJob( - UUID.randomUUID(), - " \u0000 ", - null, - "report.docx", - "application/octet-stream", - "hash", - 10L, - 3 - ); - - assertEquals(TenantContext.DEMO_TENANT_ID, job.getTenantId()); - assertEquals(TenantContext.DEMO_SUBJECT_ID, job.getSubjectId()); - assertFalse(job.belongsToTenant(null)); - } - @Test void clampsMaxAttemptsToOneWhenZeroIsConfigured() { ConversionJob job = new ConversionJob(