diff --git a/.jules/sentinel.md b/.jules/sentinel.md index e795cb9d..f99f7027 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-08-13 - [CRITICAL] Admin API 인증 및 권한 검증 누락 수정 +**Vulnerability:** `AdminController`의 관리자 전용 API(전체 작업 조회, 작업 삭제, 데드레터 재시도 등)에 대해 사용자 인증 및 권한(tenant:configure) 검증이 누락되어 누구나 관리자 기능을 호출할 수 있었습니다. +**Learning:** 새로운 컨트롤러 추가 시 기존의 `TenantAccessService`를 통한 AOP/필터 기반 공통 처리가 아닌, 각 메서드 내에서 명시적인 권한 요구(tenantAccessService.require) 방식을 사용하고 있어 휴먼 에러로 검증 코드가 쉽게 누락될 수 있었습니다. +**Prevention:** 모든 엔드포인트는 추가 시 반드시 권한 검증 로직이 포함되었는지 확인하고, 보안 문서에 정의된 권한(`docs/security/2026-07-02-auth-tenant-model.md`의 `tenant_admin` 권한)과 일치하는 상수(예: `TENANT_CONFIGURE`)를 적용해야 합니다. diff --git a/docs/deployment/clearfolio-buyer-connector.openapi.yaml b/docs/deployment/clearfolio-buyer-connector.openapi.yaml index 6dc4dc20..cb1e90f6 100644 --- a/docs/deployment/clearfolio-buyer-connector.openapi.yaml +++ b/docs/deployment/clearfolio-buyer-connector.openapi.yaml @@ -5,10 +5,10 @@ info: description: > Import seed for a buyer-owned gateway or Power Platform custom connector. This spec reflects the current Clearfolio Viewer JSON API contract and the - signed tenant-header scaffold. It is not a production OIDC/JWT profile. + buyer-demo signed-header scaffold. It is not a production OIDC/JWT profile. license: - name: Apache-2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html + name: Proprietary - buyer diligence use only + url: https://github.com/ContextualWisdomLab/clearfolio servers: - url: https://{gatewayHost} description: Buyer gateway in front of Clearfolio Viewer. @@ -19,15 +19,13 @@ security: - clearfolioTenantHeaders: [] tags: - name: Conversion - description: Document intake, status polling, deletion, and retry operations. + description: Document intake, status polling, and retry operations. - name: Viewer description: Viewer bootstrap operations that return signed artifact URLs. - name: ArtifactLinks - description: Signed artifact-link creation, revocation, byte delivery, and read evidence. + description: Signed artifact-link creation, revocation, and read evidence. - name: Analytics description: Tenant-scoped KPI snapshot and export evidence operations. - - name: Operations - description: Operational endpoints intentionally exposed without tenant identity. paths: /api/v1/convert/jobs: post: @@ -100,28 +98,6 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" - delete: - tags: - - Conversion - operationId: deleteConversionJob - summary: Delete a conversion job and generated artifacts. - description: Requires `job:delete`; missing and cross-tenant jobs are hidden. - parameters: - - $ref: "#/components/parameters/JobId" - - $ref: "#/components/parameters/TenantId" - - $ref: "#/components/parameters/SubjectId" - - $ref: "#/components/parameters/Permissions" - - $ref: "#/components/parameters/ClaimsIssuedAt" - - $ref: "#/components/parameters/ClaimsSignature" - responses: - "204": - description: Conversion job and generated artifacts deleted. - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" /api/v1/convert/jobs/{jobId}/retry: post: tags: @@ -223,7 +199,7 @@ paths: tags: - ArtifactLinks operationId: revokeArtifactLink - summary: Revoke a previously issued signed artifact link. + summary: Revoke a previously issued artifact link. description: Requires `artifact-link:revoke`; intended for operators. parameters: - $ref: "#/components/parameters/TokenId" @@ -278,57 +254,6 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" - /artifacts/{docId}.pdf: - get: - tags: - - ArtifactLinks - operationId: getPdfArtifact - summary: Read a converted PDF through a signed artifact token. - description: > - Serves bytes only for a succeeded conversion with an issued, unexpired, - unrevoked, document/tenant/checksum-bound artifact token. The token may - be supplied either as the `artifactToken` query parameter or as a Bearer - credential. At most one `bytes` range is accepted and verified reads are - recorded by the server. - security: - - artifactTokenQuery: [] - - artifactTokenBearer: [] - parameters: - - $ref: "#/components/parameters/DocId" - - $ref: "#/components/parameters/Range" - responses: - "200": - description: Full PDF artifact. - content: - application/pdf: - schema: - type: string - format: binary - "206": - description: Requested single byte range of the PDF artifact. - headers: - Content-Range: - description: Returned byte range and complete PDF length as `bytes start-end/total`. - schema: - type: string - content: - application/pdf: - schema: - type: string - format: binary - "401": - description: Signed artifact token is missing, malformed, expired, or otherwise unauthorized. - "403": - description: Signed artifact token is revoked or otherwise forbidden. - "404": - description: Conversion job or PDF artifact is absent or unavailable. - "416": - description: Range is malformed or unsatisfiable for the current PDF bytes. - headers: - Content-Range: - description: Complete PDF length as `bytes */total`. - schema: - type: string /api/v1/analytics/kpi-snapshot: get: tags: @@ -379,24 +304,6 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" - /healthz: - get: - tags: - - Operations - operationId: getHealthz - summary: Check process liveness. - description: > - Returns only the static process-liveness payload shipped on protected main. - This endpoint intentionally requires no tenant identity and does not claim - dependency or traffic readiness. - security: [] - responses: - "200": - description: Process is alive. - content: - application/json: - schema: - $ref: "#/components/schemas/HealthResponse" components: securitySchemes: clearfolioTenantHeaders: @@ -407,16 +314,6 @@ components: Buyer gateway supplies the full signed Clearfolio tenant header set. The signature alone is not enough without tenant, subject, permissions, and issued-at headers. - artifactTokenQuery: - type: apiKey - in: query - name: artifactToken - description: Short-lived Clearfolio artifact token issued for this document. - artifactTokenBearer: - type: http - scheme: bearer - bearerFormat: ClearfolioArtifactToken - description: Short-lived Clearfolio artifact token supplied as an Authorization bearer credential. parameters: TenantId: name: X-Clearfolio-Tenant-Id @@ -424,14 +321,14 @@ components: required: true schema: type: string - example: tenant-example + example: buyer-demo SubjectId: name: X-Clearfolio-Subject-Id in: header required: true schema: type: string - example: operator-example + example: buyer-demo-operator Permissions: name: X-Clearfolio-Permissions in: header @@ -443,7 +340,7 @@ components: The gateway must sign this canonical form, not the raw string. schema: type: string - example: job:create,job:read,job:delete,viewer:read,artifact-link:create,analytics:read + example: job:create,job:read,viewer:read,artifact-link:create,analytics:read ClaimsIssuedAt: name: X-Clearfolio-Claims-Issued-At in: header @@ -505,13 +402,6 @@ components: required: true schema: type: string - Range: - name: Range - in: header - required: false - description: Optional single HTTP byte range, for example `bytes=0-1023`. - schema: - type: string responses: BadRequest: description: Bad request or validation failure. @@ -544,16 +434,6 @@ components: schema: $ref: "#/components/schemas/ApiErrorResponse" schemas: - HealthResponse: - type: object - required: - - status - additionalProperties: false - properties: - status: - type: string - enum: - - ok SubmitConversionResponse: type: object required: @@ -790,4 +670,4 @@ components: type: string details: type: object - additionalProperties: true \ No newline at end of file + additionalProperties: true diff --git a/scripts/test_buyer_openapi_license_contract.py b/scripts/test_buyer_openapi_license_contract.py deleted file mode 100644 index e9f26e68..00000000 --- a/scripts/test_buyer_openapi_license_contract.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""Keep buyer OpenAPI acquisition metadata aligned with repository authority.""" - -from __future__ import annotations - -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -OPENAPI_PATH = REPOSITORY_ROOT / "docs/deployment/clearfolio-buyer-connector.openapi.yaml" -LICENSE_PATH = REPOSITORY_ROOT / "LICENSE" - - -class BuyerOpenApiLicenseContractTest(unittest.TestCase): - """Prevent acquisition-facing API metadata from contradicting product authority.""" - - def test_openapi_does_not_claim_proprietary_repository_licensing(self) -> None: - """Reject the superseded proprietary-only buyer-diligence claim.""" - - repository_license = LICENSE_PATH.read_text(encoding="utf-8") - openapi = OPENAPI_PATH.read_text(encoding="utf-8") - - self.assertTrue(repository_license.startswith("Apache License\nVersion 2.0")) - self.assertNotIn("Proprietary - buyer diligence use only", openapi) - - def test_openapi_examples_do_not_encode_buyer_demo_identity(self) -> None: - """Keep machine-readable integration examples independent of demo authority.""" - - openapi = OPENAPI_PATH.read_text(encoding="utf-8") - - self.assertNotIn("buyer-demo", openapi) - self.assertNotIn("buyer-demo-operator", openapi) - self.assertIn("example: tenant-example", openapi) - self.assertIn("example: operator-example", openapi) - - -if __name__ == "__main__": - unittest.main() diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 4f9c6a68..73befa76 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -55,6 +55,11 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to configure tenant or access admin endpoints. + */ + public static final String TENANT_CONFIGURE = "tenant:configure"; + 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 412d4eb8..c32c3313 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,17 +4,22 @@ import java.util.List; import java.util.UUID; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import com.clearfolio.viewer.api.AdminJobListResponse; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -25,25 +30,37 @@ @RestController public class AdminController { + /** 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 deadLettered optional filter for dead-lettered jobs + * @param headers request headers * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) final Boolean deadLettered, + @RequestHeader final HttpHeaders headers) { + tenantAccessService.require( + headers, TenantPermissions.TENANT_CONFIGURE); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -63,11 +80,15 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * Deletes a conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { - conversionService.deleteJob(jobId); + public ResponseEntity deleteJob(@PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + TenantContext tenantContext = tenantAccessService.require( + headers, TenantPermissions.TENANT_CONFIGURE); + conversionService.deleteJob(jobId, tenantContext); return ResponseEntity.noContent().build(); } @@ -75,16 +96,24 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * Retries a dead-lettered conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { - RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); + public ResponseEntity retryDeadLettered( + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + tenantAccessService.require( + headers, TenantPermissions.TENANT_CONFIGURE); + RetryDeadLetterResult result = conversionService.retryDeadLettered( + jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job 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"); + throw new ResponseStatusException( + HttpStatus.CONFLICT, "job is not eligible for retry"); } return ResponseEntity.accepted().build(); } diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java deleted file mode 100644 index 120e724d..00000000 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiLicenseContractTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.clearfolio.viewer.config; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.Reader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.Yaml; - -/** - * Verifies acquisition-facing OpenAPI license metadata through the parsed YAML structure. - */ -class BuyerOpenApiLicenseContractTest { - - private static final Path OPENAPI_PATH = - Path.of("docs/deployment/clearfolio-buyer-connector.openapi.yaml"); - private static final Path LICENSE_PATH = Path.of("LICENSE"); - - @Test - void openApiLicenseObjectMatchesRepositoryAuthority() throws IOException { - String repositoryLicense = Files.readString(LICENSE_PATH); - assertTrue(repositoryLicense.startsWith("Apache License\nVersion 2.0")); - - Object parsed; - try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { - parsed = new Yaml().load(reader); - } - - Map root = assertInstanceOf(Map.class, parsed); - Map info = assertInstanceOf(Map.class, root.get("info")); - Map license = assertInstanceOf(Map.class, info.get("license")); - - assertEquals("Apache-2.0", license.get("name")); - assertEquals("https://www.apache.org/licenses/LICENSE-2.0.html", license.get("url")); - } -} diff --git a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java b/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java deleted file mode 100644 index 50071256..00000000 --- a/src/test/java/com/clearfolio/viewer/config/BuyerOpenApiRouteContractTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.clearfolio.viewer.config; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.io.Reader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; -import org.yaml.snakeyaml.Yaml; - -/** - * Verifies buyer-facing OpenAPI coverage for public routes already shipped on protected main. - */ -class BuyerOpenApiRouteContractTest { - - private static final Path OPENAPI_PATH = - Path.of("docs/deployment/clearfolio-buyer-connector.openapi.yaml"); - - @Test - void openApiDescribesProtectedMainConversionJobDeleteRoute() throws IOException { - Map root = loadOpenApi(); - Map paths = assertInstanceOf(Map.class, root.get("paths")); - Map jobPath = assertInstanceOf(Map.class, paths.get("/api/v1/convert/jobs/{jobId}")); - Map deleteOperation = assertInstanceOf(Map.class, jobPath.get("delete")); - - assertEquals("deleteConversionJob", deleteOperation.get("operationId")); - assertTrue(String.valueOf(deleteOperation.get("description")).contains("job:delete")); - assertHasParameter(root, deleteOperation, "jobId", "path", true); - - Map responses = assertInstanceOf(Map.class, deleteOperation.get("responses")); - assertTrue(responses.containsKey("204")); - assertTrue(responses.containsKey("401")); - assertTrue(responses.containsKey("403")); - assertTrue(responses.containsKey("404")); - } - - @Test - void openApiDescribesProtectedMainSignedArtifactByteRoute() throws IOException { - Map root = loadOpenApi(); - Map paths = assertInstanceOf(Map.class, root.get("paths")); - Map artifactPath = assertInstanceOf(Map.class, paths.get("/artifacts/{docId}.pdf")); - Map getOperation = assertInstanceOf(Map.class, artifactPath.get("get")); - - assertEquals("getPdfArtifact", getOperation.get("operationId")); - assertHasParameter(root, getOperation, "docId", "path", true); - assertHasParameter(root, getOperation, "Range", "header", false); - assertEquals( - List.of( - Map.of("artifactTokenQuery", List.of()), - Map.of("artifactTokenBearer", List.of())), - getOperation.get("security")); - - Map responses = assertInstanceOf(Map.class, getOperation.get("responses")); - for (String status : List.of("200", "206", "401", "403", "404", "416")) { - assertTrue(responses.containsKey(status), "missing artifact response " + status); - } - assertContentRangeHeader(responses, "206", "bytes start-end/total"); - assertContentRangeHeader(responses, "416", "bytes */total"); - - Map components = assertInstanceOf(Map.class, root.get("components")); - Map securitySchemes = assertInstanceOf(Map.class, components.get("securitySchemes")); - Map queryScheme = assertInstanceOf(Map.class, securitySchemes.get("artifactTokenQuery")); - assertEquals("apiKey", queryScheme.get("type")); - assertEquals("query", queryScheme.get("in")); - assertEquals("artifactToken", queryScheme.get("name")); - - Map bearerScheme = assertInstanceOf(Map.class, securitySchemes.get("artifactTokenBearer")); - assertEquals("http", bearerScheme.get("type")); - assertEquals("bearer", bearerScheme.get("scheme")); - } - - @Test - void openApiDescribesUnauthenticatedProtectedMainLivenessRoute() throws IOException { - Map root = loadOpenApi(); - Map paths = assertInstanceOf(Map.class, root.get("paths")); - Map healthPath = assertInstanceOf(Map.class, paths.get("/healthz")); - Map getOperation = assertInstanceOf(Map.class, healthPath.get("get")); - - assertEquals("getHealthz", getOperation.get("operationId")); - assertEquals(List.of(), getOperation.get("security")); - - Map responses = assertInstanceOf(Map.class, getOperation.get("responses")); - Map okResponse = assertInstanceOf(Map.class, responses.get("200")); - Map content = assertInstanceOf(Map.class, okResponse.get("content")); - Map jsonContent = assertInstanceOf(Map.class, content.get("application/json")); - Map schema = assertInstanceOf(Map.class, jsonContent.get("schema")); - assertEquals("#/components/schemas/HealthResponse", schema.get("$ref")); - } - - private static void assertHasParameter( - Map root, - Map operation, - String expectedName, - String expectedLocation, - boolean expectedRequired - ) { - List parameters = assertInstanceOf(List.class, operation.get("parameters")); - assertTrue( - parameters.stream() - .map(parameter -> resolveParameter(root, parameter)) - .anyMatch(parameter -> expectedName.equals(parameter.get("name")) - && expectedLocation.equals(parameter.get("in")) - && Boolean.valueOf(expectedRequired).equals(parameter.get("required"))), - "missing parameter " + expectedName + " in " + expectedLocation - ); - } - - private static void assertContentRangeHeader( - Map responses, - String status, - String expectedDescriptionFragment - ) { - Map response = assertInstanceOf(Map.class, responses.get(status)); - Map headers = assertInstanceOf(Map.class, response.get("headers")); - Map contentRange = assertInstanceOf(Map.class, headers.get("Content-Range")); - Map schema = assertInstanceOf(Map.class, contentRange.get("schema")); - assertEquals("string", schema.get("type")); - assertTrue( - String.valueOf(contentRange.get("description")).contains(expectedDescriptionFragment), - "Content-Range description for " + status + " must explain " + expectedDescriptionFragment - ); - } - - private static Map resolveParameter(Map root, Object parameterValue) { - Map parameter = assertInstanceOf(Map.class, parameterValue); - Object reference = parameter.get("$ref"); - if (reference == null) { - return parameter; - } - - String prefix = "#/components/parameters/"; - String referenceText = assertInstanceOf(String.class, reference); - assertTrue(referenceText.startsWith(prefix), "unsupported parameter reference " + referenceText); - - Map components = assertInstanceOf(Map.class, root.get("components")); - Map componentParameters = assertInstanceOf(Map.class, components.get("parameters")); - return assertInstanceOf(Map.class, componentParameters.get(referenceText.substring(prefix.length()))); - } - - private static Map loadOpenApi() throws IOException { - Object parsed; - try (Reader reader = Files.newBufferedReader(OPENAPI_PATH)) { - parsed = new Yaml().load(reader); - } - return assertInstanceOf(Map.class, parsed); - } -} diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a801..d51e478d 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,15 +1,21 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Arrays; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; +import 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; @@ -17,16 +23,21 @@ 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(); + + TenantContext dummyContext = new TenantContext("test-tenant", "test-subject", Set.of(TenantPermissions.TENANT_CONFIGURE)); + when(tenantAccessService.require(any(), eq(TenantPermissions.TENANT_CONFIGURE))).thenReturn(dummyContext); } @Test @@ -82,6 +93,7 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId)