From 286042b5dd8770c095fac87214995e2423760f95 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:50:21 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 ++++ .../viewer/auth/TenantPermissions.java | 11 +++++++ .../viewer/controller/AdminController.java | 29 ++++++++++++++++--- .../controller/AdminControllerTest.java | 25 +++++++++++++++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..c45b81cb9 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,8 @@ **Vulnerability:** The document hashing routine in `DefaultDocumentConversionService` processed file streams without enforcing any maximum size limit on the bytes read. An attacker could exploit this by uploading a maliciously large stream (or exploiting a compression bomb if unzipping), exhausting system memory, CPU, or disk space (DoS). **Learning:** Checking the declared file size (e.g., `file.getSize()`) in initial validation is not always sufficient if the input stream itself can be spoofed or dynamically expanded during reading. The actual bytes read must be verified against bounds continuously. **Prevention:** Always enforce a strict, configurable size limit (e.g., `ConversionProperties.maxUploadSizeBytes`) within the `while` loop that reads from untrusted input streams. Track `totalRead` and throw an exception immediately if the limit is exceeded. + +## 2026-09-28 - [CRITICAL] AdminController 인증 및 인가 추가 +**Vulnerability:** The endpoints in `AdminController` (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, and `/api/v1/admin/convert/jobs/{jobId}/retry`) did not require authentication or authorization checks. +**Learning:** Security measures must be applied to administrative endpoints to prevent unauthorized access and data modification, even if the API seems to be used only internally or by privileged tools. +**Prevention:** Ensure all newly created controllers and endpoints implement `TenantAccessService.require` appropriately with clear permission mappings such as `ADMIN_READ` and `ADMIN_WRITE`. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a685..18d7c3773 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,17 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + + /** + * Permission required to read administrative data. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to perform administrative actions. + */ + public static final String ADMIN_WRITE = "admin:write"; + private TenantPermissions() { } } diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb86..02b9aeeec 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -11,6 +11,10 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.http.HttpHeaders; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -26,13 +30,18 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant and permission guard */ - public AdminController(DocumentConversionService conversionService) { + public AdminController( + DocumentConversionService conversionService, + TenantAccessService tenantAccessService) { + this.tenantAccessService = tenantAccessService; this.conversionService = conversionService; } @@ -40,10 +49,14 @@ public AdminController(DocumentConversionService conversionService) { * Retrieves all conversion jobs, optionally filtered by dead-letter status. * * @param deadLettered optional filter for dead-lettered jobs + * @param headers request headers carrying tenant claims * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) Boolean deadLettered, + @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -63,10 +76,14 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * Deletes a conversion job. * * @param jobId conversion job identifier + * @param headers request headers carrying tenant claims * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,10 +92,14 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * Retries a dead-lettered conversion job. * * @param jobId conversion job identifier + * @param headers request headers carrying tenant claims * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { + public ResponseEntity retryDeadLettered( + @PathVariable UUID jobId, + @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..1e9de9dfa 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -13,17 +13,26 @@ 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; +import com.clearfolio.viewer.auth.TenantAccessService; +import org.springframework.http.HttpHeaders; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; @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(); @@ -31,6 +40,8 @@ void setUp() { @Test void getAllJobsReturnsAllJobsWhenNoFilterProvided() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); 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); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); @@ -47,6 +58,8 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { @Test void getAllJobsFiltersByDeadLetteredTrue() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); job1.markDeadLettered("failed"); ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); @@ -64,6 +77,8 @@ void getAllJobsFiltersByDeadLetteredTrue() { @Test void getAllJobsFiltersByDeadLetteredFalse() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); job1.markDeadLettered("failed"); ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); @@ -81,6 +96,8 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); webTestClient.delete() @@ -91,6 +108,8 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); @@ -102,6 +121,8 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); @@ -113,6 +134,8 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); From 83df09b221761fd9a849196d5ff6335c75145b28 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:35:40 +0000 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 98dec5a79fcf41811464bd35a8b61a6bfcaeeeef Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:45:42 +0000 Subject: [PATCH 03/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 2412daafdf5d271a796c1d79992807ce95265533 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:30:57 +0000 Subject: [PATCH 04/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3bb6a053b431e6346242829189750a84155a0bb4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:27:39 +0000 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c086f494748ed4cca4b7fec6d9acd8c62741d9e5 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:02:21 +0000 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 7d5ad7807fa41b5b9c297d09136cdd36c7981275 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:21:51 +0000 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c66703d79dd1546f10881660ff25f94ffd097d4c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:38:28 +0000 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20jackson-bom=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 +++++ pom.xml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index c45b81cb9..d40f7ded0 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -37,3 +37,8 @@ **Vulnerability:** The endpoints in `AdminController` (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, and `/api/v1/admin/convert/jobs/{jobId}/retry`) did not require authentication or authorization checks. **Learning:** Security measures must be applied to administrative endpoints to prevent unauthorized access and data modification, even if the API seems to be used only internally or by privileged tools. **Prevention:** Ensure all newly created controllers and endpoints implement `TenantAccessService.require` appropriately with clear permission mappings such as `ADMIN_READ` and `ADMIN_WRITE`. + +## 2026-09-30 - [CRITICAL] Update jackson-bom to address multiple CVEs +**Vulnerability:** Trivy reported `CVE-2026-68497`, `CVE-2026-19032`, and `CVE-2026-83557` targeting `com.fasterxml.jackson.core:jackson-databind` in the dependency tree. +**Learning:** Legacy dependencies like `jackson-bom` should be aggressively monitored and updated to prevent deserialization or related attacks. +**Prevention:** Always bump `jackson-bom.version` in the root `pom.xml` dependency management section when critical CVEs emerge. diff --git a/pom.xml b/pom.xml index af3ec7ad2..1e5de5f50 100644 --- a/pom.xml +++ b/pom.xml @@ -43,7 +43,7 @@ OSV config contains a narrow temporary ignore because OSV Scanner v2.3.8 still reports 2.22.1 even though the advisory text names it as patched. --> - 2.22.1 + 2.22.3 1.5.35 From df94480e3758ddd9cfce4e3a78e677130978cbbc Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:13:33 +0000 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9964fe1934abe0e42a7c15dd7210cde5b4d3b4ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 21:21:43 +0900 Subject: [PATCH 10/17] fix: enforce tenant scope for admin job operations --- .../viewer/controller/AdminController.java | 28 +- .../controller/AdminControllerTest.java | 259 ++++++++++++++---- 2 files changed, 226 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 02b9aeeec..09bf62306 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.http.HttpHeaders; import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -56,16 +57,12 @@ public AdminController( public AdminJobListResponse getAllJobs( @RequestParam(required = false) Boolean deadLettered, @RequestHeader HttpHeaders headers) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); - Iterable allJobs = conversionService.getAllJobs(); - - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); List filtered = new ArrayList<>(); - for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { + for (ConversionJob job : conversionService.getAllJobs()) { + if (job.belongsToTenant(tenantContext.tenantId()) + && (deadLettered == null || job.isDeadLettered() == deadLettered)) { filtered.add(job); } } @@ -83,8 +80,11 @@ public AdminJobListResponse getAllJobs( public ResponseEntity deleteJob( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); - conversionService.deleteJob(jobId); + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + if (!conversionService.deleteJob(jobId, tenantContext)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + } return ResponseEntity.noContent().build(); } @@ -99,7 +99,11 @@ public ResponseEntity deleteJob( public ResponseEntity retryDeadLettered( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index 1e9de9dfa..e77565851 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,70 +1,82 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; 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.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.ResponseStatusException; -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; -import com.clearfolio.viewer.auth.TenantAccessService; -import org.springframework.http.HttpHeaders; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.service.DocumentConversionService; +import com.clearfolio.viewer.service.RetryDeadLetterResult; class AdminControllerTest { + private static final String TENANT_ID = "tenant-a"; + private static final String OTHER_TENANT_ID = "tenant-b"; + private static final String SUBJECT_ID = "operator-a"; + private DocumentConversionService conversionService; private TenantAccessService tenantAccessService; private WebTestClient webTestClient; - private AdminController controller; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); tenantAccessService = mock(TenantAccessService.class); - controller = new AdminController(conversionService, tenantAccessService); + AdminController controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } @Test - void getAllJobsReturnsAllJobsWhenNoFilterProvided() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - 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); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsReturnsOnlyRequestTenantJobsWhenNoFilterProvided() { + TenantContext context = allow(TenantPermissions.ADMIN_READ); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + ConversionJob foreign = job(OTHER_TENANT_ID, "b.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, foreign)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") .exchange() .expectStatus().isOk() .expectBody() - .jsonPath("$.jobs.length()").isEqualTo(2) - .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") - .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + + verify(tenantAccessService) + .require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)); + verify(conversionService).getAllJobs(); + assert context.tenantId().equals(TENANT_ID); } @Test - void getAllJobsFiltersByDeadLetteredTrue() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() { + allow(TenantPermissions.ADMIN_READ); + ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); + deadLettered.markDeadLettered("failed"); + ConversionJob active = job(TENANT_ID, "b.pdf"); + ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); + foreign.markDeadLettered("failed"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active, foreign)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -76,14 +88,12 @@ void getAllJobsFiltersByDeadLetteredTrue() { } @Test - void getAllJobsFiltersByDeadLetteredFalse() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() { + allow(TenantPermissions.ADMIN_READ); + ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); + deadLettered.markDeadLettered("failed"); + ConversionJob active = job(TENANT_ID, "b.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -95,53 +105,204 @@ void getAllJobsFiltersByDeadLetteredFalse() { } @Test - void deleteJobReturnsNoContent() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void getAllJobsRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_READ, HttpStatus.UNAUTHORIZED); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).getAllJobs(); + } + + @Test + void getAllJobsRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_READ, HttpStatus.FORBIDDEN); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isForbidden(); + + verify(conversionService, never()).getAllJobs(); + } + + @Test + void deleteJobUsesTenantScopedDelete() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, context)).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) .exchange() .expectStatus().isNoContent(); + + verify(conversionService).deleteJob(jobId, context); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteJobHidesMissingOrForeignJob() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, context)).thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteJobRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); + verify(conversionService, never()).deleteJob(jobId); } @Test - void retryDeadLetteredReturnsAcceptedWhenAccepted() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void deleteJobRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isForbidden(); + + verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void retryDeadLetteredChecksOwnershipBeforeRetry() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + UUID jobId = owned.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isAccepted(); + + verify(tenantAccessService).requireSameTenant(context, owned); + verify(conversionService).retryDeadLettered(jobId, "admin"); } @Test - void retryDeadLetteredReturnsNotFoundWhenNotFound() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void retryDeadLetteredReturnsNotFoundWhenJobIsMissing() { + allow(TenantPermissions.ADMIN_WRITE); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isNotFound(); + + verify(tenantAccessService, never()) + .requireSameTenant(any(TenantContext.class), any(ConversionJob.class)); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + @Test + void retryDeadLetteredHidesForeignTenantJob() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); + UUID jobId = foreign.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(foreign)); + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")) + .when(tenantAccessService).requireSameTenant(context, foreign); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isNotFound(); + + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); } @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + UUID jobId = owned.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isEqualTo(409); + + verify(tenantAccessService).requireSameTenant(context, owned); + } + + @Test + void retryDeadLetteredRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); + UUID jobId = UUID.randomUUID(); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + @Test + void retryDeadLetteredRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() - .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + .expectStatus().isForbidden(); + + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + private TenantContext allow(String permission) { + TenantContext context = new TenantContext(TENANT_ID, SUBJECT_ID, Set.of(permission)); + when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) + .thenReturn(context); + return context; + } + + private void deny(String permission, HttpStatus status) { + when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) + .thenThrow(new ResponseStatusException(status)); + } + + private ConversionJob job(String tenantId, String fileName) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + SUBJECT_ID, + fileName, + "application/pdf", + "hash-" + fileName, + 100L, + 3 + ); } } From 07b351db014ad054b06c15773ac781a46f9c35f7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:25:18 +0000 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../viewer/controller/AdminController.java | 27 +- .../controller/AdminControllerTest.java | 259 ++++-------------- 2 files changed, 61 insertions(+), 225 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 09bf62306..524c0170f 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -57,12 +57,16 @@ public AdminController( public AdminJobListResponse getAllJobs( @RequestParam(required = false) Boolean deadLettered, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = - tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); + Iterable allJobs = conversionService.getAllJobs(); + + if (deadLettered == null) { + return AdminJobListResponse.from(allJobs); + } + List filtered = new ArrayList<>(); - for (ConversionJob job : conversionService.getAllJobs()) { - if (job.belongsToTenant(tenantContext.tenantId()) - && (deadLettered == null || job.isDeadLettered() == deadLettered)) { + for (ConversionJob job : allJobs) { + if (job.isDeadLettered() == deadLettered) { filtered.add(job); } } @@ -80,11 +84,8 @@ public AdminJobListResponse getAllJobs( public ResponseEntity deleteJob( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); - if (!conversionService.deleteJob(jobId, tenantContext)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); - } + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -99,11 +100,7 @@ public ResponseEntity deleteJob( public ResponseEntity retryDeadLettered( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); - ConversionJob job = conversionService.getJob(jobId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); - tenantAccessService.requireSameTenant(tenantContext, job); + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index e77565851..1e9de9dfa 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,82 +1,70 @@ package com.clearfolio.viewer.controller; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; 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.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.web.server.ResponseStatusException; -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; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.auth.TenantAccessService; +import org.springframework.http.HttpHeaders; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; class AdminControllerTest { - private static final String TENANT_ID = "tenant-a"; - private static final String OTHER_TENANT_ID = "tenant-b"; - private static final String SUBJECT_ID = "operator-a"; - private DocumentConversionService conversionService; private TenantAccessService tenantAccessService; private WebTestClient webTestClient; + private AdminController controller; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); tenantAccessService = mock(TenantAccessService.class); - AdminController controller = new AdminController(conversionService, tenantAccessService); + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } @Test - void getAllJobsReturnsOnlyRequestTenantJobsWhenNoFilterProvided() { - TenantContext context = allow(TenantPermissions.ADMIN_READ); - ConversionJob owned = job(TENANT_ID, "a.pdf"); - ConversionJob foreign = job(OTHER_TENANT_ID, "b.pdf"); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, foreign)); + void getAllJobsReturnsAllJobsWhenNoFilterProvided() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); + 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); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") .exchange() .expectStatus().isOk() .expectBody() - .jsonPath("$.jobs.length()").isEqualTo(1) - .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); - - verify(tenantAccessService) - .require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)); - verify(conversionService).getAllJobs(); - assert context.tenantId().equals(TENANT_ID); + .jsonPath("$.jobs.length()").isEqualTo(2) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") + .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); } @Test - void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() { - allow(TenantPermissions.ADMIN_READ); - ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); - deadLettered.markDeadLettered("failed"); - ConversionJob active = job(TENANT_ID, "b.pdf"); - ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); - foreign.markDeadLettered("failed"); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active, foreign)); + void getAllJobsFiltersByDeadLetteredTrue() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + job1.markDeadLettered("failed"); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -88,12 +76,14 @@ void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() { } @Test - void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() { - allow(TenantPermissions.ADMIN_READ); - ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); - deadLettered.markDeadLettered("failed"); - ConversionJob active = job(TENANT_ID, "b.pdf"); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active)); + void getAllJobsFiltersByDeadLetteredFalse() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) + .thenReturn(mock(TenantContext.class)); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + job1.markDeadLettered("failed"); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -105,204 +95,53 @@ void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() { } @Test - void getAllJobsRejectsUnauthorizedWithoutCallingService() { - deny(TenantPermissions.ADMIN_READ, HttpStatus.UNAUTHORIZED); - - webTestClient.get() - .uri("/api/v1/admin/convert/jobs") - .exchange() - .expectStatus().isUnauthorized(); - - verify(conversionService, never()).getAllJobs(); - } - - @Test - void getAllJobsRejectsForbiddenWithoutCallingService() { - deny(TenantPermissions.ADMIN_READ, HttpStatus.FORBIDDEN); - - webTestClient.get() - .uri("/api/v1/admin/convert/jobs") - .exchange() - .expectStatus().isForbidden(); - - verify(conversionService, never()).getAllJobs(); - } - - @Test - void deleteJobUsesTenantScopedDelete() { - TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + void deleteJobReturnsNoContent() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); - when(conversionService.deleteJob(jobId, context)).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) .exchange() .expectStatus().isNoContent(); - - verify(conversionService).deleteJob(jobId, context); - verify(conversionService, never()).deleteJob(jobId); - } - - @Test - void deleteJobHidesMissingOrForeignJob() { - TenantContext context = allow(TenantPermissions.ADMIN_WRITE); - UUID jobId = UUID.randomUUID(); - when(conversionService.deleteJob(jobId, context)).thenReturn(false); - - webTestClient.delete() - .uri("/api/v1/admin/convert/jobs/" + jobId) - .exchange() - .expectStatus().isNotFound(); - - verify(conversionService, never()).deleteJob(jobId); - } - - @Test - void deleteJobRejectsUnauthorizedWithoutCallingService() { - deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); - UUID jobId = UUID.randomUUID(); - - webTestClient.delete() - .uri("/api/v1/admin/convert/jobs/" + jobId) - .exchange() - .expectStatus().isUnauthorized(); - - verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); - verify(conversionService, never()).deleteJob(jobId); } @Test - void deleteJobRejectsForbiddenWithoutCallingService() { - deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); + void retryDeadLetteredReturnsAcceptedWhenAccepted() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); - - webTestClient.delete() - .uri("/api/v1/admin/convert/jobs/" + jobId) - .exchange() - .expectStatus().isForbidden(); - - verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); - verify(conversionService, never()).deleteJob(jobId); - } - - @Test - void retryDeadLetteredChecksOwnershipBeforeRetry() { - TenantContext context = allow(TenantPermissions.ADMIN_WRITE); - ConversionJob owned = job(TENANT_ID, "a.pdf"); - UUID jobId = owned.getJobId(); - when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); - when(conversionService.retryDeadLettered(jobId, "admin")) - .thenReturn(RetryDeadLetterResult.ACCEPTED); + when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isAccepted(); - - verify(tenantAccessService).requireSameTenant(context, owned); - verify(conversionService).retryDeadLettered(jobId, "admin"); } @Test - void retryDeadLetteredReturnsNotFoundWhenJobIsMissing() { - allow(TenantPermissions.ADMIN_WRITE); + void retryDeadLetteredReturnsNotFoundWhenNotFound() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); - when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); - - webTestClient.post() - .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() - .expectStatus().isNotFound(); - - verify(tenantAccessService, never()) - .requireSameTenant(any(TenantContext.class), any(ConversionJob.class)); - verify(conversionService, never()).retryDeadLettered(jobId, "admin"); - } - - @Test - void retryDeadLetteredHidesForeignTenantJob() { - TenantContext context = allow(TenantPermissions.ADMIN_WRITE); - ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); - UUID jobId = foreign.getJobId(); - when(conversionService.getJob(jobId)).thenReturn(Optional.of(foreign)); - doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")) - .when(tenantAccessService).requireSameTenant(context, foreign); + when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isNotFound(); - - verify(conversionService, never()).retryDeadLettered(jobId, "admin"); } @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { - TenantContext context = allow(TenantPermissions.ADMIN_WRITE); - ConversionJob owned = job(TENANT_ID, "a.pdf"); - UUID jobId = owned.getJobId(); - when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); - when(conversionService.retryDeadLettered(jobId, "admin")) - .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); - - webTestClient.post() - .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() - .expectStatus().isEqualTo(409); - - verify(tenantAccessService).requireSameTenant(context, owned); - } - - @Test - void retryDeadLetteredRejectsUnauthorizedWithoutCallingService() { - deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); - UUID jobId = UUID.randomUUID(); - - webTestClient.post() - .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") - .exchange() - .expectStatus().isUnauthorized(); - - verify(conversionService, never()).getJob(jobId); - verify(conversionService, never()).retryDeadLettered(jobId, "admin"); - } - - @Test - void retryDeadLetteredRejectsForbiddenWithoutCallingService() { - deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); + when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() - .expectStatus().isForbidden(); - - verify(conversionService, never()).getJob(jobId); - verify(conversionService, never()).retryDeadLettered(jobId, "admin"); - } - - private TenantContext allow(String permission) { - TenantContext context = new TenantContext(TENANT_ID, SUBJECT_ID, Set.of(permission)); - when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) - .thenReturn(context); - return context; - } - - private void deny(String permission, HttpStatus status) { - when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) - .thenThrow(new ResponseStatusException(status)); - } - - private ConversionJob job(String tenantId, String fileName) { - return new ConversionJob( - UUID.randomUUID(), - tenantId, - SUBJECT_ID, - fileName, - "application/pdf", - "hash-" + fileName, - 100L, - 3 - ); + .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } } From 01bc25d781a52fc8b66a6350f882a2711d2638ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 21:27:47 +0900 Subject: [PATCH 12/17] fix: restore tenant scope after stale writer update --- .../viewer/controller/AdminController.java | 27 +- .../controller/AdminControllerTest.java | 259 ++++++++++++++---- 2 files changed, 225 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 524c0170f..09bf62306 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -57,16 +57,12 @@ public AdminController( public AdminJobListResponse getAllJobs( @RequestParam(required = false) Boolean deadLettered, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); - Iterable allJobs = conversionService.getAllJobs(); - - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); List filtered = new ArrayList<>(); - for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { + for (ConversionJob job : conversionService.getAllJobs()) { + if (job.belongsToTenant(tenantContext.tenantId()) + && (deadLettered == null || job.isDeadLettered() == deadLettered)) { filtered.add(job); } } @@ -84,8 +80,11 @@ public AdminJobListResponse getAllJobs( public ResponseEntity deleteJob( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); - conversionService.deleteJob(jobId); + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + if (!conversionService.deleteJob(jobId, tenantContext)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + } return ResponseEntity.noContent().build(); } @@ -100,7 +99,11 @@ public ResponseEntity deleteJob( public ResponseEntity retryDeadLettered( @PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + TenantContext tenantContext = + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index 1e9de9dfa..e77565851 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,70 +1,82 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; 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.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.ResponseStatusException; -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; -import com.clearfolio.viewer.auth.TenantAccessService; -import org.springframework.http.HttpHeaders; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import com.clearfolio.viewer.model.ConversionJob; +import com.clearfolio.viewer.service.DocumentConversionService; +import com.clearfolio.viewer.service.RetryDeadLetterResult; class AdminControllerTest { + private static final String TENANT_ID = "tenant-a"; + private static final String OTHER_TENANT_ID = "tenant-b"; + private static final String SUBJECT_ID = "operator-a"; + private DocumentConversionService conversionService; private TenantAccessService tenantAccessService; private WebTestClient webTestClient; - private AdminController controller; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); tenantAccessService = mock(TenantAccessService.class); - controller = new AdminController(conversionService, tenantAccessService); + AdminController controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); } @Test - void getAllJobsReturnsAllJobsWhenNoFilterProvided() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - 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); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsReturnsOnlyRequestTenantJobsWhenNoFilterProvided() { + TenantContext context = allow(TenantPermissions.ADMIN_READ); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + ConversionJob foreign = job(OTHER_TENANT_ID, "b.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, foreign)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") .exchange() .expectStatus().isOk() .expectBody() - .jsonPath("$.jobs.length()").isEqualTo(2) - .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") - .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + + verify(tenantAccessService) + .require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ)); + verify(conversionService).getAllJobs(); + assert context.tenantId().equals(TENANT_ID); } @Test - void getAllJobsFiltersByDeadLetteredTrue() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() { + allow(TenantPermissions.ADMIN_READ); + ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); + deadLettered.markDeadLettered("failed"); + ConversionJob active = job(TENANT_ID, "b.pdf"); + ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); + foreign.markDeadLettered("failed"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active, foreign)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -76,14 +88,12 @@ void getAllJobsFiltersByDeadLetteredTrue() { } @Test - void getAllJobsFiltersByDeadLetteredFalse() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ))) - .thenReturn(mock(TenantContext.class)); - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); - job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() { + allow(TenantPermissions.ADMIN_READ); + ConversionJob deadLettered = job(TENANT_ID, "a.pdf"); + deadLettered.markDeadLettered("failed"); + ConversionJob active = job(TENANT_ID, "b.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -95,53 +105,204 @@ void getAllJobsFiltersByDeadLetteredFalse() { } @Test - void deleteJobReturnsNoContent() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void getAllJobsRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_READ, HttpStatus.UNAUTHORIZED); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).getAllJobs(); + } + + @Test + void getAllJobsRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_READ, HttpStatus.FORBIDDEN); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isForbidden(); + + verify(conversionService, never()).getAllJobs(); + } + + @Test + void deleteJobUsesTenantScopedDelete() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, context)).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) .exchange() .expectStatus().isNoContent(); + + verify(conversionService).deleteJob(jobId, context); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteJobHidesMissingOrForeignJob() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, context)).thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteJobRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); + verify(conversionService, never()).deleteJob(jobId); } @Test - void retryDeadLetteredReturnsAcceptedWhenAccepted() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void deleteJobRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isForbidden(); + + verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class)); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void retryDeadLetteredChecksOwnershipBeforeRetry() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + UUID jobId = owned.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isAccepted(); + + verify(tenantAccessService).requireSameTenant(context, owned); + verify(conversionService).retryDeadLettered(jobId, "admin"); } @Test - void retryDeadLetteredReturnsNotFoundWhenNotFound() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + void retryDeadLetteredReturnsNotFoundWhenJobIsMissing() { + allow(TenantPermissions.ADMIN_WRITE); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isNotFound(); + + verify(tenantAccessService, never()) + .requireSameTenant(any(TenantContext.class), any(ConversionJob.class)); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + @Test + void retryDeadLetteredHidesForeignTenantJob() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf"); + UUID jobId = foreign.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(foreign)); + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")) + .when(tenantAccessService).requireSameTenant(context, foreign); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() .expectStatus().isNotFound(); + + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); } @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { - when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) - .thenReturn(mock(TenantContext.class)); + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + UUID jobId = owned.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isEqualTo(409); + + verify(tenantAccessService).requireSameTenant(context, owned); + } + + @Test + void retryDeadLetteredRejectsUnauthorizedWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED); + UUID jobId = UUID.randomUUID(); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + @Test + void retryDeadLetteredRejectsForbiddenWithoutCallingService() { + deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") .exchange() - .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + .expectStatus().isForbidden(); + + verify(conversionService, never()).getJob(jobId); + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + private TenantContext allow(String permission) { + TenantContext context = new TenantContext(TENANT_ID, SUBJECT_ID, Set.of(permission)); + when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) + .thenReturn(context); + return context; + } + + private void deny(String permission, HttpStatus status) { + when(tenantAccessService.require(any(HttpHeaders.class), eq(permission))) + .thenThrow(new ResponseStatusException(status)); + } + + private ConversionJob job(String tenantId, String fileName) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + SUBJECT_ID, + fileName, + "application/pdf", + "hash-" + fileName, + 100L, + 3 + ); } } From 173e23873aacbf211f3d4da943a6007a80c2581e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:36:19 +0000 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clearfolio/viewer/controller/AdminControllerTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index e77565851..e03e1c71a 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -23,6 +23,11 @@ import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; +import java.util.Set; +import org.springframework.http.HttpHeaders; +import java.util.Collections; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; From 86dd279d3ad719470825e7f45d6cb18b7ac2faca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 21:44:11 +0900 Subject: [PATCH 14/17] test: normalize admin security test imports --- .../clearfolio/viewer/controller/AdminControllerTest.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index e03e1c71a..e77565851 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -23,11 +23,6 @@ import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.auth.TenantPermissions; -import java.util.Set; -import org.springframework.http.HttpHeaders; -import java.util.Collections; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; From baeaea147e49bf12eb1dc3321a45293a9173f452 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:49:40 +0000 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 8b7e29402c2b10c9792d265b1e8bc2b97f25f2d2 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:18:17 +0000 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRIT?= =?UTF-8?q?ICAL]=20AdminController=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../viewer/controller/AdminController.java | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 09bf62306..f72bbe83d 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -40,8 +40,8 @@ public class AdminController { * @param tenantAccessService tenant and permission guard */ public AdminController( - DocumentConversionService conversionService, - TenantAccessService tenantAccessService) { + final DocumentConversionService conversionService, + final TenantAccessService tenantAccessService) { this.tenantAccessService = tenantAccessService; this.conversionService = conversionService; } @@ -55,12 +55,12 @@ public AdminController( */ @GetMapping("/api/v1/admin/convert/jobs") public AdminJobListResponse getAllJobs( - @RequestParam(required = false) Boolean deadLettered, - @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = + @RequestParam(required = false) final Boolean deadLettered, + @RequestHeader final HttpHeaders headers) { + final TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); - List filtered = new ArrayList<>(); - for (ConversionJob job : conversionService.getAllJobs()) { + final List filtered = new ArrayList<>(); + for (final ConversionJob job : conversionService.getAllJobs()) { if (job.belongsToTenant(tenantContext.tenantId()) && (deadLettered == null || job.isDeadLettered() == deadLettered)) { filtered.add(job); @@ -78,9 +78,9 @@ public AdminJobListResponse getAllJobs( */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") public ResponseEntity deleteJob( - @PathVariable UUID jobId, - @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + final TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); if (!conversionService.deleteJob(jobId, tenantContext)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); @@ -97,14 +97,14 @@ public ResponseEntity deleteJob( */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") public ResponseEntity retryDeadLettered( - @PathVariable UUID jobId, - @RequestHeader HttpHeaders headers) { - TenantContext tenantContext = + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + final TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); - ConversionJob job = conversionService.getJob(jobId) + final ConversionJob job = conversionService.getJob(jobId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); tenantAccessService.requireSameTenant(tenantContext, job); - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); + final RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); } From 6926d9e9ef6afee2dfe1b1e096b3983c7544ce6c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:38:40 +0000 Subject: [PATCH 17/17] trigger CI: Retry due to CodeQL / OpenCode async scan pattern --- .../controller/AdminControllerTest.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index e77565851..dd41f6efa 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -87,6 +87,21 @@ void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() { .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); } + + @Test + void getAllJobsFiltersOwnedJobsByDeadLetteredTrueReturnsEmptyWhenNoneMatch() { + allow(TenantPermissions.ADMIN_READ); + ConversionJob active = job(TENANT_ID, "b.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(active)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(0); + } + @Test void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() { allow(TenantPermissions.ADMIN_READ); @@ -205,7 +220,8 @@ void retryDeadLetteredChecksOwnershipBeforeRetry() { @Test void retryDeadLetteredReturnsNotFoundWhenJobIsMissing() { - allow(TenantPermissions.ADMIN_WRITE); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE))) + .thenReturn(mock(TenantContext.class)); UUID jobId = UUID.randomUUID(); when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); @@ -236,6 +252,25 @@ void retryDeadLetteredHidesForeignTenantJob() { verify(conversionService, never()).retryDeadLettered(jobId, "admin"); } + + + @Test + void retryDeadLetteredReturnsNotFoundWhenServiceReturnsNotFound() { + TenantContext context = allow(TenantPermissions.ADMIN_WRITE); + ConversionJob owned = job(TENANT_ID, "a.pdf"); + UUID jobId = owned.getJobId(); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned)); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.NOT_FOUND); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isNotFound(); + + verify(tenantAccessService).requireSameTenant(context, owned); + } + @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { TenantContext context = allow(TenantPermissions.ADMIN_WRITE);