Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ default Optional<ConversionJob> findByTenantAndId(String tenantId, UUID jobId) {
*/
List<ConversionJob> findAll();

/**
* Returns only jobs owned by the supplied tenant.
*
* <p>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.</p>
*
* @param tenantId authenticated tenant identifier
* @return tenant-owned jobs, or an empty list until the adapter implements
* the scoped query
*/
default List<ConversionJob> findAllByTenantId(String tenantId) {
return List.of();
}

/**
* Finds jobs that should be considered for recovery after worker restart.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ public List<ConversionJob> findAll() {
return List.copyOf(jobs.values());
}

/**
* {@inheritDoc}
*/
@Override
public List<ConversionJob> 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}
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading