From a45d45efe05f328d888f63e74aac02873c7b6084 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:09:01 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20AdminController=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=9A=B0=ED=9A=8C=20=EC=B7=A8=EC=95=BD=EC=A0=90=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `TenantPermissions.java`에 관리자 전용 권한(`ADMIN_READ`, `ADMIN_WRITE`) 추가 * `AdminController.java`에 `TenantAccessService` 주입 및 각 엔드포인트에 권한 검증 추가 * `AdminControllerTest.java`에서 누락된 권한 검증 로직 테스트 가능하도록 수정 * `.jules/sentinel.md`에 보안 관련 인사이트(Learning) 기록 완료 --- .jules/sentinel.md | 4 ++ .../viewer/auth/TenantPermissions.java | 10 ++++ .../viewer/controller/AdminController.java | 51 +++++++++++++++---- .../controller/AdminControllerTest.java | 12 ++++- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9dd..06499e1fb 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -32,3 +32,7 @@ **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-02-21 - 관리자 엔드포인트 인증 우회 (Admin Controller Authentication Bypass) +**Vulnerability:** `AdminController`의 엔드포인트(`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}` 등)에 어떠한 인증 및 인가 로직도 적용되어 있지 않아, 누구나 변환 작업을 조회, 삭제, 재시도할 수 있었습니다. +**Learning:** 커스텀 권한 부여 흐름(`TenantAccessService` 주입 등)을 사용하는 애플리케이션에서는 개발자가 명시적으로 서비스 호출을 누락할 경우 기본적으로 엔드포인트가 '모두 허용(open by default)' 상태가 되는 위험이 있습니다. +**Prevention:** 1) 새로운 관리자 컨트롤러를 작성할 때는 반드시 `TenantAccessService`를 주입받아 각 엔드포인트마다 RBAC 권한(`admin:read`, `admin:write`)을 강제해야 합니다. 2) 장기적으로는 아키텍처가 허용한다면 필터나 애노테이션 기반(예: `@PreAuthorize`)의 중앙집중식 보안 모델로 전환하여 '기본 거부(default-deny)' 정책을 구축하는 것이 좋습니다. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a685..1fec549c7 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,16 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to read admin endpoints. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to perform write actions on admin endpoints. + */ + 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..398861e33 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,17 +4,21 @@ 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.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -25,25 +29,37 @@ @RestController public class AdminController { + /** Document conversion service. */ private final DocumentConversionService conversionService; + /** Tenant access service. */ + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * - * @param conversionService conversion service + * @param conversionSvc conversion service + * @param tenantAccessSvc tenant access service */ - public AdminController(DocumentConversionService conversionService) { - this.conversionService = conversionService; + public AdminController( + final DocumentConversionService conversionSvc, + final TenantAccessService tenantAccessSvc) { + this.conversionService = conversionSvc; + this.tenantAccessService = tenantAccessSvc; } /** * Retrieves all conversion jobs, optionally filtered by dead-letter status. * + * @param headers request headers carrying tenant claims * @param deadLettered optional filter for dead-lettered jobs * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs( + @RequestHeader final HttpHeaders headers, + @RequestParam(required = false) final Boolean deadLettered) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); + Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -62,11 +78,16 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d /** * Deletes a conversion job. * + * @param headers request headers carrying tenant claims * @param jobId conversion job identifier * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @RequestHeader final HttpHeaders headers, + @PathVariable final UUID jobId) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -74,17 +95,25 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { /** * Retries a dead-lettered conversion job. * + * @param headers request headers carrying tenant claims * @param jobId conversion job identifier * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); - if (result == RetryDeadLetterResult.NOT_FOUND) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + public ResponseEntity retryDeadLettered( + @RequestHeader final HttpHeaders headers, + @PathVariable final UUID jobId) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + + RetryDeadLetterResult res = conversionService.retryDeadLettered( + jobId, "admin"); + if (res == RetryDeadLetterResult.NOT_FOUND) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "job not found"); } - if (result == RetryDeadLetterResult.NOT_ELIGIBLE) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "job is not eligible for retry"); + if (res == RetryDeadLetterResult.NOT_ELIGIBLE) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "job is not eligible for retry"); } return ResponseEntity.accepted().build(); } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..e2cff1b42 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; +import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,13 +18,15 @@ 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(); @@ -37,6 +40,7 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isOk() .expectBody() @@ -55,6 +59,7 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isOk() .expectBody() @@ -72,6 +77,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isOk() .expectBody() @@ -85,6 +91,7 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isNoContent(); } @@ -96,6 +103,7 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isAccepted(); } @@ -107,6 +115,7 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isNotFound(); } @@ -118,6 +127,7 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } From 5385d814715edefec9c1bdae9b73993c3ba35dcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:19:05 +0900 Subject: [PATCH 2/4] test: prove admin tenant isolation --- .../controller/AdminControllerTest.java | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index e2cff1b42..186366484 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,9 +1,12 @@ package com.clearfolio.viewer.controller; 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.UUID; import org.junit.jupiter.api.BeforeEach; @@ -11,6 +14,8 @@ 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; @@ -49,6 +54,91 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); } + @Test + void getAllJobsOnlyReturnsJobsOwnedByRequestTenant() { + tenantAccessService = new TenantAccessService(); + controller = new AdminController(conversionService, tenantAccessService); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + ConversionJob owned = ownedJob("tenant-1", "owned.pdf"); + ConversionJob other = ownedJob("tenant-2", "other.pdf"); + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, other)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .headers(headers -> addAdminHeaders(headers, TenantPermissions.ADMIN_READ)) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("owned.pdf"); + } + + @Test + void deleteJobHidesCrossTenantJobAsNotFound() { + tenantAccessService = new TenantAccessService(); + controller = new AdminController(conversionService, tenantAccessService); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob( + org.mockito.ArgumentMatchers.eq(jobId), + org.mockito.ArgumentMatchers.any(TenantContext.class))) + .thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(headers -> addAdminHeaders(headers, TenantPermissions.ADMIN_WRITE)) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void retryDeadLetteredHidesCrossTenantJobAsNotFound() { + tenantAccessService = new TenantAccessService(); + controller = new AdminController(conversionService, tenantAccessService); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + UUID jobId = UUID.randomUUID(); + when(conversionService.getJob(jobId)) + .thenReturn(Optional.of(ownedJob("tenant-2", "other.pdf"))); + when(conversionService.retryDeadLettered(jobId, "admin")) + .thenReturn(RetryDeadLetterResult.ACCEPTED); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .headers(headers -> addAdminHeaders(headers, TenantPermissions.ADMIN_WRITE)) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService, never()).retryDeadLettered(jobId, "admin"); + } + + @Test + void adminEndpointsRejectMissingAndInsufficientClaims() { + tenantAccessService = new TenantAccessService(); + controller = new AdminController(conversionService, tenantAccessService); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isUnauthorized(); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + UUID.randomUUID()) + .headers(headers -> addAdminHeaders(headers, TenantPermissions.ADMIN_READ)) + .exchange() + .expectStatus().isForbidden(); + } + @Test void getAllJobsFiltersByDeadLetteredTrue() { ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); @@ -131,4 +221,24 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } + + private static ConversionJob ownedJob(final String tenantId, final String fileName) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + "subject-1", + fileName, + "application/pdf", + "hash-" + fileName, + 100L, + 3); + } + + private static void addAdminHeaders( + final org.springframework.http.HttpHeaders headers, + final String permission) { + headers.add(TenantContext.TENANT_ID_HEADER, "tenant-1"); + headers.add(TenantContext.SUBJECT_ID_HEADER, "admin-1"); + headers.add(TenantContext.PERMISSIONS_HEADER, permission); + } } From ce7080d43f7ee5252e14899c610694d969686611 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:35:52 +0900 Subject: [PATCH 3/4] fix: enforce tenant scope in admin operations --- .../viewer/controller/AdminController.java | 20 ++- .../service/DocumentConversionService.java | 45 +++++++ .../controller/AdminControllerTest.java | 46 ++++--- ...DocumentConversionServiceCoverageTest.java | 115 ++++++++++++++++++ 4 files changed, 204 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 398861e33..139a03a49 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -18,6 +18,7 @@ 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; @@ -58,9 +59,11 @@ public AdminController( public AdminJobListResponse getAllJobs( @RequestHeader final HttpHeaders headers, @RequestParam(required = false) final Boolean deadLettered) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_READ); - Iterable allJobs = conversionService.getAllJobs(); + Iterable allJobs = conversionService.getAllJobs( + tenantContext); if (deadLettered == null) { return AdminJobListResponse.from(allJobs); @@ -86,9 +89,13 @@ public AdminJobListResponse getAllJobs( public ResponseEntity deleteJob( @RequestHeader final HttpHeaders headers, @PathVariable final UUID jobId) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_WRITE); - conversionService.deleteJob(jobId); + if (!conversionService.deleteJob(jobId, tenantContext)) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "job not found"); + } return ResponseEntity.noContent().build(); } @@ -103,10 +110,11 @@ public ResponseEntity deleteJob( public ResponseEntity retryDeadLettered( @RequestHeader final HttpHeaders headers, @PathVariable final UUID jobId) { - tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.ADMIN_WRITE); RetryDeadLetterResult res = conversionService.retryDeadLettered( - jobId, "admin"); + jobId, "admin", tenantContext); if (res == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException( HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java index 6676f5610..c6c4353c4 100644 --- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java @@ -60,6 +60,32 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T */ RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId); + /** + * Retries a dead-lettered conversion job owned by the supplied tenant. + * + * @param jobId conversion job identifier + * @param operatorId operator identifier that triggered the retry + * @param tenantContext tenant and subject claims for the retry request + * @return retry outcome, or {@link RetryDeadLetterResult#NOT_FOUND} when + * the job is missing or belongs to another tenant + */ + default RetryDeadLetterResult retryDeadLettered( + UUID jobId, + String operatorId, + TenantContext tenantContext) { + if (tenantContext == null) { + return RetryDeadLetterResult.NOT_FOUND; + } + Optional job = getJob(jobId); + if (job.isEmpty()) { + return RetryDeadLetterResult.NOT_FOUND; + } + if (!job.get().belongsToTenant(tenantContext.tenantId())) { + return RetryDeadLetterResult.NOT_FOUND; + } + return retryDeadLettered(jobId, operatorId); + } + /** * Deletes a conversion job owned by the supplied tenant context. * @@ -95,4 +121,23 @@ default boolean deleteJob(UUID jobId, TenantContext tenantContext) { * @return an iterable of all conversion jobs */ Iterable getAllJobs(); + + /** + * Returns only conversion jobs owned by the supplied tenant. + * + * @param tenantContext tenant and subject claims for the list request + * @return an iterable containing only jobs owned by that tenant + */ + default Iterable getAllJobs(TenantContext tenantContext) { + if (tenantContext == null) { + return java.util.List.of(); + } + java.util.List ownedJobs = new java.util.ArrayList<>(); + for (ConversionJob job : getAllJobs()) { + if (job.belongsToTenant(tenantContext.tenantId())) { + ownedJobs.add(job); + } + } + return ownedJobs; + } } diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index 186366484..7618628a5 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,12 +1,15 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; 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; @@ -26,11 +29,18 @@ class AdminControllerTest { private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; + private TenantContext tenantContext; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); tenantAccessService = mock(TenantAccessService.class); + tenantContext = new TenantContext( + "tenant-1", "admin-1", Set.of( + TenantPermissions.ADMIN_READ, + TenantPermissions.ADMIN_WRITE)); + when(tenantAccessService.require(any(), anyString())) + .thenReturn(tenantContext); controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) @@ -41,7 +51,8 @@ void setUp() { 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); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(conversionService.getAllJobs(tenantContext)) + .thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") @@ -62,8 +73,8 @@ void getAllJobsOnlyReturnsJobsOwnedByRequestTenant() { .controllerAdvice(new ApiExceptionHandler()) .build(); ConversionJob owned = ownedJob("tenant-1", "owned.pdf"); - ConversionJob other = ownedJob("tenant-2", "other.pdf"); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, other)); + when(conversionService.getAllJobs(any(TenantContext.class))) + .thenReturn(java.util.List.of(owned)); webTestClient.get() .uri("/api/v1/admin/convert/jobs") @@ -83,9 +94,7 @@ void deleteJobHidesCrossTenantJobAsNotFound() { .controllerAdvice(new ApiExceptionHandler()) .build(); UUID jobId = UUID.randomUUID(); - when(conversionService.deleteJob( - org.mockito.ArgumentMatchers.eq(jobId), - org.mockito.ArgumentMatchers.any(TenantContext.class))) + when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))) .thenReturn(false); webTestClient.delete() @@ -105,10 +114,9 @@ void retryDeadLetteredHidesCrossTenantJobAsNotFound() { .controllerAdvice(new ApiExceptionHandler()) .build(); UUID jobId = UUID.randomUUID(); - when(conversionService.getJob(jobId)) - .thenReturn(Optional.of(ownedJob("tenant-2", "other.pdf"))); - when(conversionService.retryDeadLettered(jobId, "admin")) - .thenReturn(RetryDeadLetterResult.ACCEPTED); + when(conversionService.retryDeadLettered( + eq(jobId), eq("admin"), any(TenantContext.class))) + .thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -145,7 +153,8 @@ void getAllJobsFiltersByDeadLetteredTrue() { job1.markDeadLettered("failed"); ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(conversionService.getAllJobs(tenantContext)) + .thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -163,7 +172,8 @@ void getAllJobsFiltersByDeadLetteredFalse() { job1.markDeadLettered("failed"); ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); - when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(conversionService.getAllJobs(tenantContext)) + .thenReturn(Arrays.asList(job1, job2)); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -178,6 +188,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(jobId, tenantContext)).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) @@ -189,7 +200,8 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(conversionService.retryDeadLettered(jobId, "admin", tenantContext)) + .thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -201,7 +213,8 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(conversionService.retryDeadLettered(jobId, "admin", tenantContext)) + .thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -213,7 +226,8 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(conversionService.retryDeadLettered(jobId, "admin", tenantContext)) + .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") diff --git a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java index 0784ca943..147ecb62d 100644 --- a/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DocumentConversionServiceCoverageTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; import java.util.Optional; import java.util.Set; @@ -63,4 +64,118 @@ public Iterable getAllJobs() { assertEquals(1, lookupCount.get()); assertEquals(0, deleteCount.get()); } + + @Test + void tenantScopedListFailsClosedAndReturnsOnlyOwnedJobs() { + ConversionJob owned = jobForTenant("tenant-a", "owned.pdf"); + ConversionJob other = jobForTenant("tenant-b", "other.pdf"); + RecordingDocumentConversionService service = + new RecordingDocumentConversionService( + java.util.List.of(owned, other)); + + assertIterableEquals( + java.util.List.of(), + service.getAllJobs(null)); + assertIterableEquals( + java.util.List.of(owned), + service.getAllJobs(contextFor("tenant-a"))); + } + + @Test + void tenantScopedRetryFailsClosedBeforeLegacyMutation() { + UUID ownedId = UUID.randomUUID(); + UUID otherId = UUID.randomUUID(); + RecordingDocumentConversionService service = + new RecordingDocumentConversionService(java.util.List.of( + jobForTenant(ownedId, "tenant-a", "owned.pdf"), + jobForTenant(otherId, "tenant-b", "other.pdf"))); + + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered(ownedId, "admin", null)); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered( + UUID.randomUUID(), "admin", contextFor("tenant-a"))); + assertEquals( + RetryDeadLetterResult.NOT_FOUND, + service.retryDeadLettered( + otherId, "admin", contextFor("tenant-a"))); + assertEquals(0, service.retryCount.get()); + + assertEquals( + RetryDeadLetterResult.ACCEPTED, + service.retryDeadLettered( + ownedId, "admin", contextFor("tenant-a"))); + assertEquals(1, service.retryCount.get()); + } + + private static TenantContext contextFor(final String tenantId) { + return new TenantContext(tenantId, "admin-1", Set.of()); + } + + private static ConversionJob jobForTenant( + final String tenantId, + final String fileName) { + return jobForTenant(UUID.randomUUID(), tenantId, fileName); + } + + private static ConversionJob jobForTenant( + final UUID jobId, + final String tenantId, + final String fileName) { + return new ConversionJob( + jobId, + tenantId, + "subject-1", + fileName, + "application/pdf", + "hash-" + fileName, + 100L, + 3); + } + + /** + * Minimal compatibility implementation used to exercise interface guards. + */ + private static final class RecordingDocumentConversionService + implements DocumentConversionService { + private final java.util.List jobs; + private final AtomicInteger retryCount = new AtomicInteger(); + + RecordingDocumentConversionService( + final java.util.List conversionJobs) { + this.jobs = conversionJobs; + } + + @Override + public UUID submit(final MultipartFile file) { + return UUID.randomUUID(); + } + + @Override + public Optional getJob(final UUID jobId) { + return jobs.stream() + .filter(job -> job.getJobId().equals(jobId)) + .findFirst(); + } + + @Override + public RetryDeadLetterResult retryDeadLettered( + final UUID jobId, + final String operatorId) { + retryCount.incrementAndGet(); + return RetryDeadLetterResult.ACCEPTED; + } + + @Override + public void deleteJob(final UUID jobId) { + // No mutation is needed for the list and retry guard tests. + } + + @Override + public Iterable getAllJobs() { + return jobs; + } + } } From 3f2d357f72939711943fb77afa6f5ad29547af43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:43:08 +0900 Subject: [PATCH 4/4] test: verify admin permission contracts --- .../viewer/controller/AdminControllerTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index 7618628a5..0b8f185f7 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -63,6 +63,10 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { .jsonPath("$.jobs.length()").isEqualTo(2) .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); + + verify(tenantAccessService).require( + any(org.springframework.http.HttpHeaders.class), + eq(TenantPermissions.ADMIN_READ)); } @Test @@ -195,6 +199,10 @@ void deleteJobReturnsNoContent() { .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isNoContent(); + + verify(tenantAccessService).require( + any(org.springframework.http.HttpHeaders.class), + eq(TenantPermissions.ADMIN_WRITE)); } @Test @@ -208,6 +216,10 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { .header("X-Clearfolio-Tenant-Id", "tenant-1") .exchange() .expectStatus().isAccepted(); + + verify(tenantAccessService).require( + any(org.springframework.http.HttpHeaders.class), + eq(TenantPermissions.ADMIN_WRITE)); } @Test