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
32 changes: 29 additions & 3 deletions src/main/java/com/clearfolio/viewer/model/ConversionJob.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.</p>
*
* @param jobId job identifier
* @param tenantId tenant isolation boundary
Expand All @@ -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;
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
20 changes: 2 additions & 18 deletions src/test/java/com/clearfolio/viewer/model/ConversionJobTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading