Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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