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
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@
import java.util.List;
import java.util.UUID;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import com.clearfolio.viewer.api.AdminJobListResponse;
import com.clearfolio.viewer.auth.TenantAccessService;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.auth.TenantPermissions;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
Expand All @@ -26,14 +31,20 @@
public class AdminController {

private final DocumentConversionService conversionService;
private final TenantAccessService tenantAccessService;

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

/**
Expand All @@ -60,14 +71,21 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
}

/**
* Deletes a conversion job.
* Deletes a conversion job owned by the authenticated tenant.
*
* @param jobId conversion job identifier
* @param headers tenant and permission claim headers
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
conversionService.deleteJob(jobId);
public ResponseEntity<Void> deleteJob(
@PathVariable UUID jobId,
@RequestHeader HttpHeaders headers
) {
TenantContext context = tenantAccessService.require(headers, TenantPermissions.JOB_DELETE);
if (!conversionService.deleteJob(jobId, context)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}
return ResponseEntity.noContent().build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.clearfolio.viewer.controller;

import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import java.util.UUID;

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

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

class AdminControllerDeleteTenantBoundaryTest {

private DocumentConversionService conversionService;
private WebTestClient webTestClient;

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

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

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.exchange()
.expectStatus().isUnauthorized();

verifyNoInteractions(conversionService);
}

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

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_READ))
.exchange()
.expectStatus().isForbidden();

verifyNoInteractions(conversionService);
}

@Test
void deleteUsesTenantScopedMutationAndConcealsMissingOrForeignJobs() {
UUID jobId = UUID.randomUUID();
when(conversionService.deleteJob(
org.mockito.ArgumentMatchers.eq(jobId),
argThat(context -> context != null && "tenant-a".equals(context.tenantId()))
)).thenReturn(false);

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_DELETE))
.exchange()
.expectStatus().isNotFound();

verify(conversionService).deleteJob(
org.mockito.ArgumentMatchers.eq(jobId),
argThat(context -> context != null && "tenant-a".equals(context.tenantId()))
);
verify(conversionService, never()).deleteJob(jobId);
}

@Test
void deleteReturnsNoContentOnlyAfterOwnedTenantScopedMutation() {
UUID jobId = UUID.randomUUID();
when(conversionService.deleteJob(
org.mockito.ArgumentMatchers.eq(jobId),
argThat(context -> context != null && "tenant-a".equals(context.tenantId()))
)).thenReturn(true);

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_DELETE))
.exchange()
.expectStatus().isNoContent();

verify(conversionService).deleteJob(
org.mockito.ArgumentMatchers.eq(jobId),
argThat(context -> context != null && "tenant-a".equals(context.tenantId()))
);
verify(conversionService, never()).deleteJob(jobId);
}

private static void authorizedHeaders(HttpHeaders headers, String permission) {
headers.set(TenantContext.TENANT_ID_HEADER, "tenant-a");
headers.set(TenantContext.SUBJECT_ID_HEADER, "operator-a");
headers.set(TenantContext.PERMISSIONS_HEADER, permission);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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;

Expand All @@ -10,6 +12,9 @@
import org.junit.jupiter.api.Test;
import org.springframework.test.web.reactive.server.WebTestClient;

import com.clearfolio.viewer.auth.TenantAccessService;
import com.clearfolio.viewer.auth.TenantContext;
import com.clearfolio.viewer.auth.TenantPermissions;
import com.clearfolio.viewer.model.ConversionJob;
import com.clearfolio.viewer.service.DocumentConversionService;
import com.clearfolio.viewer.service.RetryDeadLetterResult;
Expand All @@ -23,7 +28,7 @@ class AdminControllerTest {
@BeforeEach
void setUp() {
conversionService = mock(DocumentConversionService.class);
controller = new AdminController(conversionService);
controller = new AdminController(conversionService, new TenantAccessService());
webTestClient = WebTestClient.bindToController(controller)
.controllerAdvice(new ApiExceptionHandler())
.build();
Expand Down Expand Up @@ -82,9 +87,13 @@ void getAllJobsFiltersByDeadLetteredFalse() {
@Test
void deleteJobReturnsNoContent() {
UUID jobId = UUID.randomUUID();
when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(true);

webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.header(TenantContext.TENANT_ID_HEADER, "tenant-a")
.header(TenantContext.SUBJECT_ID_HEADER, "operator-a")
.header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_DELETE)
.exchange()
.expectStatus().isNoContent();
}
Expand Down
Loading