Skip to content
Open
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
Expand Up @@ -26,4 +26,11 @@ public interface ArtifactStore {
* @return PDF bytes when present
*/
Optional<byte[]> getPdf(UUID docId);

/**
* Deletes stored PDF bytes for a document.
*
* @param docId document identifier
*/
void deletePdf(UUID docId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,12 @@ public Optional<byte[]> getPdf(UUID docId) {
}
return Optional.of(bytes.clone());
}

/**
* {@inheritDoc}
*/
@Override
public void deletePdf(UUID docId) {
pdfByDocId.remove(docId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public final class TenantPermissions {
*/
public static final String JOB_READ = "job:read";

/**
* Permission required to delete a conversion job.
*/
public static final String JOB_DELETE = "job:delete";

/**
* Permission required to retry a dead-lettered conversion job.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.DeleteMapping;

import com.clearfolio.viewer.auth.TenantAccessService;
import com.clearfolio.viewer.auth.TenantContext;
Expand Down Expand Up @@ -51,6 +52,7 @@ public class ConversionController {
private final DocumentConversionService conversionService;
private final TenantAccessService tenantAccessService;
private final ArtifactLinkService artifactLinkService;
private final com.clearfolio.viewer.artifact.ArtifactStore artifactStore;
private final int maxInMemorySizeBytes;

/**
Expand All @@ -59,16 +61,19 @@ public class ConversionController {
* @param conversionService conversion service
* @param tenantAccessService tenant and permission guard
* @param artifactLinkService signed artifact link service
* @param artifactStore artifact store
* @param maxInMemorySize maximum in-memory multipart size
*/
public ConversionController(
DocumentConversionService conversionService,
TenantAccessService tenantAccessService,
ArtifactLinkService artifactLinkService,
com.clearfolio.viewer.artifact.ArtifactStore artifactStore,
@Value("${spring.codec.max-in-memory-size:262144B}") DataSize maxInMemorySize) {
this.conversionService = conversionService;
this.tenantAccessService = tenantAccessService;
this.artifactLinkService = artifactLinkService;
this.artifactStore = artifactStore;
long bytes = Math.max(1L, maxInMemorySize.toBytes());
this.maxInMemorySizeBytes = bytes > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) bytes;
}
Expand Down Expand Up @@ -160,6 +165,26 @@ public ResponseEntity<SubmitConversionResponse> retryDeadLettered(
return ResponseEntity.status(HttpStatus.ACCEPTED).body(SubmitConversionResponse.accepted(jobId));
}

/**
* Deletes a conversion job and its associated generated artifacts.
*
* @param jobId conversion job identifier
* @param headers request headers carrying tenant claims
* @return no content on success
*/
@DeleteMapping("/api/v1/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) {
TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.JOB_DELETE);
ConversionJob job = conversionService.getJob(jobId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"));
tenantAccessService.requireSameTenant(tenantContext, job);
Comment on lines +175 to +180

conversionService.deleteJob(jobId);
artifactStore.deletePdf(jobId);

return ResponseEntity.noContent().build();
Comment on lines +182 to +185
}

/**
* Returns viewer bootstrap data once conversion output is ready.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,11 @@ default Optional<ConversionJob> findByTenantAndContentHash(String tenantId, Stri
* @return canonical stored conversion job and whether the candidate was created
*/
FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate);

/**
* Deletes a conversion job by identifier.
*
* @param jobId conversion job identifier
*/
void deleteById(UUID jobId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ public List<ConversionJob> findAll() {
return List.copyOf(jobs.values());
}

/**
* {@inheritDoc}
*/
@Override
public void deleteById(UUID jobId) {
ConversionJob removed = jobs.remove(jobId);
if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) {
jobsByTenantAndContentHash.remove(contentKey(removed.getTenantId(), removed.getContentHash()), jobId);
}
}

/**
* Returns lifecycle events for a conversion job.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ public Optional<ConversionJob> getJob(UUID jobId) {
return repository.findById(jobId);
}

/**
* {@inheritDoc}
*/
@Override
public void deleteJob(UUID jobId) {
repository.deleteById(jobId);
}
Comment on lines +137 to +139

/**
* {@inheritDoc}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,11 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T
* @return retry outcome
*/
RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId);

/**
* Deletes a conversion job.
*
* @param jobId conversion job identifier
*/
void deleteJob(UUID jobId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ ConversionController conversionController(DocumentConversionService conversionSe
conversionService,
new TenantAccessService(),
new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"),
new InMemoryArtifactStore(),
org.springframework.util.unit.DataSize.ofBytes(2048L)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ void setUp() {
conversionService,
new TenantAccessService(),
new ArtifactLinkService(artifactStore, "test-secret"),
artifactStore,
DataSize.ofBytes(262_144L)
);
webTestClient = WebTestClient.bindToController(
Expand All @@ -70,6 +71,7 @@ void constructorCapsMaxInMemorySizeAtIntegerMaxValue() throws Exception {
conversionService,
new TenantAccessService(),
new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"),
new InMemoryArtifactStore(),
DataSize.ofBytes((long) Integer.MAX_VALUE + 1)
);
Field field = ConversionController.class.getDeclaredField("maxInMemorySizeBytes");
Expand Down Expand Up @@ -644,6 +646,64 @@ void viewerAliasRoutesReturnBootstrapWhenReady() {
}
}

@Test
void deleteJobRequiresDeletePermission() {
UUID jobId = UUID.randomUUID();

webTestClient.delete()
.uri("/api/v1/convert/jobs/{jobId}", jobId)
.header(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID)
.header(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID)
.header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) // Missing JOB_DELETE
.exchange()
.expectStatus().isForbidden()
.expectBody()
.jsonPath("$.errorCode").isEqualTo("FORBIDDEN")
.jsonPath("$.message").value(value -> assertContains((String) value, TenantPermissions.JOB_DELETE));
}

@Test
void deleteJobReturnsNotFoundWhenJobMissing() {
UUID jobId = UUID.randomUUID();
when(conversionService.getJob(jobId)).thenReturn(Optional.empty());

webTestClient.delete()
.uri("/api/v1/convert/jobs/{jobId}", jobId)
.headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE))
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.errorCode").isEqualTo("NOT_FOUND")
.jsonPath("$.message").isEqualTo("job not found");
}

@Test
void deleteJobDeletesJobAndArtifact() {
UUID jobId = UUID.randomUUID();
ConversionJob job = new ConversionJob(
jobId,
TenantContext.DEMO_TENANT_ID,
TenantContext.DEMO_SUBJECT_ID,
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"abc",
12L,
3
);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));
artifactStore.putPdf(jobId, new byte[] {1, 2, 3});

webTestClient.delete()
.uri("/api/v1/convert/jobs/{jobId}", jobId)
.headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE))
.exchange()
.expectStatus().isNoContent()
.expectBody().isEmpty();

verify(conversionService).deleteJob(jobId);
assertFalse(artifactStore.getPdf(jobId).isPresent());
}

private WebTestClient.ResponseSpec submit(String filename, byte[] content) {
return submit(filename, content, null, null, null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) {
public List<ConversionJob> findAll() {
return List.of(job);
}

@Override
public void deleteById(UUID jobId) {
}
};

assertSame(job, repository.findByTenantAndContentHash("tenant-a", "hash").orElseThrow());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -960,5 +960,10 @@ public List<ConversionJob> findAll() {
public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) {
return delegate.findOrStoreByContentHash(candidate);
}

@Override
public void deleteById(UUID jobId) {
delegate.deleteById(jobId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,11 @@ public java.util.List<ConversionJob> findAll() {
public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) {
return finder.apply(candidate);
}

@Override
public void deleteById(UUID jobId) {
throw new UnsupportedOperationException("not used");
}
}

private static class RecordingStateStore implements ConversionJobStateStore {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public Optional<ConversionJob> getJob(UUID jobId) {
public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.NOT_FOUND;
}

@Override
public void deleteJob(UUID jobId) {
}
};

UUID actual = service.submit(
Expand Down Expand Up @@ -67,6 +71,10 @@ public Optional<ConversionJob> getJob(UUID jobId) {
public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) {
return RetryDeadLetterResult.NOT_FOUND;
}

@Override
public void deleteJob(UUID jobId) {
}
};
PolicyOverrideRequest overrideRequest = PolicyOverrideRequest.of("true", "token-123", "approver-1");

Expand Down
Loading