Skip to content
Closed
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
63 changes: 53 additions & 10 deletions src/main/java/com/clearfolio/viewer/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,58 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.RequestHeader;

import com.clearfolio.viewer.api.AdminJobListResponse;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
import com.clearfolio.viewer.auth.TenantAccessService;

/**
* Controller for admin-specific endpoints.
*/
@RestController
public class AdminController {

private static final java.util.HexFormat HEX_FORMAT = java.util.HexFormat.of();

private final DocumentConversionService conversionService;
private final TenantAccessService tenantAccessService;

/**
* Creates a controller for admin operations.
*
* @param conversionService conversion service
* @param tenantAccessService tenant access service for authorization
*/
public AdminController(DocumentConversionService conversionService) {
public AdminController(DocumentConversionService conversionService, TenantAccessService tenantAccessService) {
this.conversionService = conversionService;
this.tenantAccessService = tenantAccessService;
}

/**
* Retrieves all conversion jobs, optionally filtered by dead-letter status.
*
* @param deadLettered optional filter for dead-lettered jobs
* @param headers request headers
* @return list of conversion jobs
*/
@GetMapping("/api/v1/admin/convert/jobs")
public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) {
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();
public AdminJobListResponse getAllJobs(
@RequestParam(required = false) Boolean deadLettered,
@RequestHeader org.springframework.http.HttpHeaders headers) {

if (deadLettered == null) {
return AdminJobListResponse.from(allJobs);
}
com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_READ);

Iterable<ConversionJob> allJobs = conversionService.getAllJobs();

List<ConversionJob> filtered = new ArrayList<>();
for (ConversionJob job : allJobs) {
if (job.isDeadLettered() == deadLettered) {
if (!job.belongsToTenant(context.tenantId())) {
continue;
}
if (deadLettered == null || job.isDeadLettered() == deadLettered) {
filtered.add(job);
}
}
Expand All @@ -63,10 +75,21 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
* Deletes a conversion job.
*
* @param jobId conversion job identifier
* @param headers request headers
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(
@PathVariable UUID jobId,
@RequestHeader org.springframework.http.HttpHeaders headers) {

com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_DELETE);
java.util.Optional<ConversionJob> job = conversionService.getJob(jobId);

if (job.isEmpty() || !job.get().belongsToTenant(context.tenantId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}

conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}
Expand All @@ -75,11 +98,31 @@ public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
* Retries a dead-lettered conversion job.
*
* @param jobId conversion job identifier
* @param headers request headers
* @return accepted response on success
*/
@PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry")
public ResponseEntity<Void> retryDeadLettered(@PathVariable UUID jobId) {
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
public ResponseEntity<Void> retryDeadLettered(
@PathVariable UUID jobId,
@RequestHeader org.springframework.http.HttpHeaders headers) {

com.clearfolio.viewer.auth.TenantContext context = tenantAccessService.require(headers, com.clearfolio.viewer.auth.TenantPermissions.JOB_RETRY);
java.util.Optional<ConversionJob> job = conversionService.getJob(jobId);

if (job.isEmpty() || !job.get().belongsToTenant(context.tenantId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}

java.security.MessageDigest digest;
try {
digest = java.security.MessageDigest.getInstance("SHA-256");
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
byte[] hash = digest.digest(context.subjectId().getBytes(java.nio.charset.StandardCharsets.UTF_8));
String operatorHash = HEX_FORMAT.formatHex(hash);

RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, operatorHash);
if (result == RetryDeadLetterResult.NOT_FOUND) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}
Expand Down
139 changes: 125 additions & 14 deletions src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
Original file line number Diff line number Diff line change
@@ -1,42 +1,65 @@
package com.clearfolio.viewer.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;

import java.util.Arrays;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.http.HttpHeaders;

import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
import com.clearfolio.viewer.auth.TenantAccessService;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.auth.TenantPermissions;

class AdminControllerTest {

private DocumentConversionService conversionService;
private TenantAccessService tenantAccessService;
private WebTestClient webTestClient;
private AdminController controller;

private static final String TEST_TENANT = "test-tenant";
private static final String OTHER_TENANT = "other-tenant";
private static final String TEST_SUBJECT = "test-operator";

@BeforeEach
void setUp() {
conversionService = mock(DocumentConversionService.class);
controller = new AdminController(conversionService);
tenantAccessService = mock(TenantAccessService.class);
controller = new AdminController(conversionService, tenantAccessService);
webTestClient = WebTestClient.bindToController(controller)
.controllerAdvice(new ApiExceptionHandler())
.build();
}

private void mockContext(String permission, String tenantId) {
TenantContext context = new TenantContext(tenantId, TEST_SUBJECT, Set.of(permission));
when(tenantAccessService.require(any(), eq(permission))).thenReturn(context);
}

@Test
void getAllJobsReturnsAllJobsWhenNoFilterProvided() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
mockContext(TenantPermissions.JOB_READ, TEST_TENANT);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -47,14 +70,17 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {

@Test
void getAllJobsFiltersByDeadLetteredTrue() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
mockContext(TenantPermissions.JOB_READ, TEST_TENANT);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
job1.markFailed("err");
job1.markDeadLettered("failed");
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isOk()
.expectBody()
Expand All @@ -64,61 +90,146 @@ void getAllJobsFiltersByDeadLetteredTrue() {

@Test
void getAllJobsFiltersByDeadLetteredFalse() {
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
mockContext(TenantPermissions.JOB_READ, TEST_TENANT);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
job1.markFailed("err");
job1.markDeadLettered("failed");
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3);

when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.jobs.length()").isEqualTo(1)
.jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf");
}

@Test
void getAllJobsFiltersByTenantId() {
mockContext(TenantPermissions.JOB_READ, TEST_TENANT);
ConversionJob job1 = new ConversionJob(UUID.randomUUID(), TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
ConversionJob job2 = new ConversionJob(UUID.randomUUID(), OTHER_TENANT, "sub", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));

webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.jobs.length()").isEqualTo(1)
.jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf");
}

@Test
void deleteJobReturnsNoContent() {
mockContext(TenantPermissions.JOB_DELETE, TEST_TENANT);
UUID jobId = UUID.randomUUID();
ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isNoContent();

verify(conversionService).deleteJob(jobId);
}

@Test
void deleteJobReturnsNotFoundWhenNotOwned() {
mockContext(TenantPermissions.JOB_DELETE, TEST_TENANT);
UUID jobId = UUID.randomUUID();
ConversionJob job = new ConversionJob(jobId, OTHER_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isNotFound();

verify(conversionService, never()).deleteJob(jobId);
}

@Test
void retryDeadLetteredReturnsAcceptedWhenAccepted() {
void retryDeadLetteredReturnsAcceptedWhenAccepted() throws Exception {
mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT);
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED);
ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String expectedHash = java.util.HexFormat.of().formatHex(hash);

when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.ACCEPTED);

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isAccepted();
}

@Test
void retryDeadLetteredReturnsNotFoundWhenNotFound() {
void retryDeadLetteredReturnsNotFoundWhenNotFound() throws Exception {
mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT);
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND);
ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String expectedHash = java.util.HexFormat.of().formatHex(hash);

when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.NOT_FOUND);

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isNotFound();
}

@Test
void retryDeadLetteredReturnsConflictWhenNotEligible() {
void retryDeadLetteredReturnsConflictWhenNotEligible() throws Exception {
mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT);
UUID jobId = UUID.randomUUID();
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
ConversionJob job = new ConversionJob(jobId, TEST_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(TEST_SUBJECT.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String expectedHash = java.util.HexFormat.of().formatHex(hash);

when(conversionService.retryDeadLettered(jobId, expectedHash)).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer
.expectStatus().isEqualTo(409);
}

@Test
void retryDeadLetteredReturnsNotFoundWhenNotOwned() {
mockContext(TenantPermissions.JOB_RETRY, TEST_TENANT);
UUID jobId = UUID.randomUUID();
ConversionJob job = new ConversionJob(jobId, OTHER_TENANT, "sub", "a.pdf", "application/pdf", "hash-a", 100L, 3);
when(conversionService.getJob(jobId)).thenReturn(Optional.of(job));

webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.header(TenantContext.TENANT_ID_HEADER, TEST_TENANT)
.exchange()
.expectStatus().isNotFound();

verify(conversionService, never()).retryDeadLettered(any(), any());
}
}
Loading