diff --git a/src/main/java/com/clearfolio/viewer/controller/AnalyticsController.java b/src/main/java/com/clearfolio/viewer/controller/AnalyticsController.java index 46a8a4e0..835cd54f 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AnalyticsController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AnalyticsController.java @@ -50,9 +50,9 @@ public AnalyticsController( @GetMapping("/api/v1/analytics/kpi-snapshot") public KpiSnapshotResponse kpiSnapshot(@RequestHeader HttpHeaders headers) { TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.ANALYTICS_READ); - KpiSnapshotResponse snapshot = KpiSnapshotResponse.from(repository.findAll().stream() - .filter(job -> job.belongsToTenant(tenantContext.tenantId())) - .toList()); + KpiSnapshotResponse snapshot = KpiSnapshotResponse.from( + repository.findAllByTenantId(tenantContext.tenantId()) + ); snapshotLedger.recordSnapshot(tenantContext, snapshot); return snapshot; } diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java index 03c00180..b1cc9ae5 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java @@ -76,6 +76,22 @@ default Optional findByTenantAndId(String tenantId, UUID jobId) { */ List findAll(); + /** + * Returns only jobs owned by the supplied tenant. + * + *

The default deliberately fails closed with an empty result instead of + * calling {@link #findAll()} and filtering after a global inventory read. + * Durable adapters must override this method with a tenant predicate at the + * storage query boundary before callers may receive job objects.

+ * + * @param tenantId authenticated tenant identifier + * @return tenant-owned jobs, or an empty list until the adapter implements + * the scoped query + */ + default List findAllByTenantId(String tenantId) { + return List.of(); + } + /** * Finds jobs that should be considered for recovery after worker restart. * diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index bb89b302..03fd355b 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -130,6 +130,20 @@ public List findAll() { return List.copyOf(jobs.values()); } + /** + * {@inheritDoc} + */ + @Override + public List findAllByTenantId(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + return List.of(); + } + String normalizedTenantId = tenantId.strip(); + return jobs.values().stream() + .filter(job -> job.belongsToTenant(normalizedTenantId)) + .toList(); + } + /** * {@inheritDoc} */ diff --git a/src/test/java/com/clearfolio/viewer/controller/AnalyticsTenantQueryBoundaryTest.java b/src/test/java/com/clearfolio/viewer/controller/AnalyticsTenantQueryBoundaryTest.java new file mode 100644 index 00000000..42f1fb8f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/AnalyticsTenantQueryBoundaryTest.java @@ -0,0 +1,120 @@ +package com.clearfolio.viewer.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +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.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.reactive.server.WebTestClient; + +import com.clearfolio.viewer.analytics.KpiSnapshotLedger; +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.repository.ConversionJobRepository; +import com.clearfolio.viewer.repository.InMemoryConversionJobRepository; + +class AnalyticsTenantQueryBoundaryTest { + + private ConversionJobRepository repository; + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + repository = mock(ConversionJobRepository.class); + webTestClient = WebTestClient.bindToController(new AnalyticsController( + repository, + new TenantAccessService(), + new KpiSnapshotLedger() + )).controllerAdvice(new ApiExceptionHandler()).build(); + } + + @Test + void kpiSnapshotUsesStorageScopedTenantQueryInsteadOfGlobalInventory() { + ConversionJob tenantJob = tenantJob("tenant-a", "operator-a", "tenant-a.docx", "tenant-a-hash"); + when(repository.findAllByTenantId("tenant-a")).thenReturn(List.of(tenantJob)); + + webTestClient.get() + .uri("/api/v1/analytics/kpi-snapshot") + .headers(AnalyticsTenantQueryBoundaryTest::addAnalyticsAuth) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.totalJobs").isEqualTo(1) + .jsonPath("$.submittedJobs").isEqualTo(1); + + verify(repository).findAllByTenantId("tenant-a"); + verify(repository, never()).findAll(); + } + + @Test + void missingAnalyticsPermissionDoesNotTouchEitherRepositoryQuery() { + webTestClient.get() + .uri("/api/v1/analytics/kpi-snapshot") + .headers(headers -> addAuth(headers, TenantPermissions.JOB_READ)) + .exchange() + .expectStatus().isForbidden(); + + verify(repository, never()).findAllByTenantId("tenant-a"); + verify(repository, never()).findAll(); + } + + @Test + void repositoryDefaultTenantQueryFailsClosedWithoutGlobalFallback() { + ConversionJobRepository defaultRepository = mock(ConversionJobRepository.class, CALLS_REAL_METHODS); + + assertTrue(defaultRepository.findAllByTenantId("tenant-a").isEmpty()); + verify(defaultRepository, never()).findAll(); + } + + @Test + void inMemoryScopedQueryFailsClosedForMissingTenantAndReturnsOnlyOwnedJobs() { + InMemoryConversionJobRepository inMemoryRepository = new InMemoryConversionJobRepository(); + ConversionJob tenantAJob = tenantJob("tenant-a", "operator-a", "tenant-a.docx", "hash-a"); + ConversionJob tenantBJob = tenantJob("tenant-b", "operator-b", "tenant-b.docx", "hash-b"); + inMemoryRepository.save(tenantAJob); + inMemoryRepository.save(tenantBJob); + + assertTrue(inMemoryRepository.findAllByTenantId(null).isEmpty()); + assertTrue(inMemoryRepository.findAllByTenantId(" ").isEmpty()); + assertEquals(List.of(tenantAJob), inMemoryRepository.findAllByTenantId(" tenant-a ")); + } + + private static ConversionJob tenantJob( + String tenantId, + String subjectId, + String fileName, + String contentHash + ) { + return new ConversionJob( + UUID.randomUUID(), + tenantId, + subjectId, + fileName, + "application/octet-stream", + contentHash, + 42L, + 3 + ); + } + + private static void addAnalyticsAuth(HttpHeaders headers) { + addAuth(headers, TenantPermissions.ANALYTICS_READ); + } + + private static void addAuth(HttpHeaders headers, String permission) { + headers.set(TenantContext.TENANT_ID_HEADER, "tenant-a"); + headers.set(TenantContext.SUBJECT_ID_HEADER, "operator-a"); + headers.set(TenantContext.PERMISSIONS_HEADER, permission); + } +}