mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-17 04:44:40 +08:00
220 lines
14 KiB
Java
220 lines
14 KiB
Java
package vip.mate.execution.evidence;
|
|
|
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
|
import vip.mate.config.JacksonConfig;
|
|
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import vip.mate.auth.service.AuthService;
|
|
import vip.mate.exception.MateClawException;
|
|
import vip.mate.execution.evidence.model.*;
|
|
import vip.mate.execution.evidence.service.*;
|
|
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
|
|
import vip.mate.tool.document.GeneratedFileCache;
|
|
import vip.mate.workspace.conversation.ConversationService;
|
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
|
import vip.mate.workspace.core.service.WorkspaceService;
|
|
|
|
import java.time.Instant;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Optional;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
class ExecutionEvidenceQueryTest {
|
|
private final ExecutionEvidenceStore store = mock(ExecutionEvidenceStore.class);
|
|
private final ConversationService conversations = mock(ConversationService.class);
|
|
private final TeamWorkerConversationGovernanceService teams = mock(TeamWorkerConversationGovernanceService.class);
|
|
private final GeneratedFileCache files = mock(GeneratedFileCache.class);
|
|
private ExecutionEvidenceQueryService queries;
|
|
private final long id = 1999999999999999999L;
|
|
private final Instant now = Instant.parse("2026-09-07T00:00:00Z");
|
|
|
|
@BeforeEach void setup() {
|
|
queries = new ExecutionEvidenceQueryService(store, conversations, teams, files,
|
|
mock(AuthService.class), mock(WorkspaceService.class), new ExecutionEvidenceProperties(), new SimpleMeterRegistry());
|
|
var conversation = new ConversationEntity();
|
|
conversation.setConversationId("conv"); conversation.setWorkspaceId(1L); conversation.setDeleted(0);
|
|
when(conversations.findByConversationId("conv")).thenReturn(conversation);
|
|
when(conversations.isConversationOwner("conv", "owner")).thenReturn(true);
|
|
var attempt = new ExecutionAttempt(1L,
|
|
new ExecutionIdentity(1L, "conv", "native", null, "call", "call", 1, "provider", "tool",
|
|
null, null, null, null, null, null, "fence"),
|
|
AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, now, now);
|
|
when(store.findAttempt(1L)).thenReturn(Optional.of(attempt));
|
|
when(store.findAttempts(1L, "conv", List.of(1L))).thenReturn(Map.of(1L, attempt));
|
|
}
|
|
|
|
@Test void deniesUnscopedAnonymousAndWrongWorkspaceBeforeReadingEvidence() {
|
|
assertEquals(404, assertThrows(MateClawException.class, () -> list("stranger", 1L, "conv", null, 20)).getCode());
|
|
assertEquals(404, assertThrows(MateClawException.class, () -> list("owner", 2L, "conv", null, 20)).getCode());
|
|
assertThrows(MateClawException.class, () -> list(null, 1L, "conv", null, 20));
|
|
assertThrows(MateClawException.class, () -> list("owner", 1L, "", null, 20));
|
|
verifyNoInteractions(store);
|
|
}
|
|
|
|
@Test void detailDoesNotLeakCrossWorkspaceOrForeignConversation() {
|
|
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("stranger", 1L, id)).getCode());
|
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 2L, id)).getCode());
|
|
assertEquals(404, assertThrows(MateClawException.class, () -> queries.detail("owner", 1L, 42L)).getCode());
|
|
}
|
|
|
|
@Test void limitsAndCursorPreserveFullPrecisionAndNeverClaimVerification() {
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(2), isNull(), isNull()))
|
|
.thenReturn(List.of(evidence(id), evidence(id - 1)));
|
|
var page = list("owner", 1L, "conv", null, 1);
|
|
assertEquals(id, page.items().getFirst().id());
|
|
assertEquals("UNKNOWN", page.items().getFirst().validity());
|
|
assertNotNull(page.nextCursor());
|
|
when(store.list(eq(1L), eq("conv"), eq(now), eq(id), eq(2), isNull(), isNull()))
|
|
.thenReturn(List.of(evidence(id - 1)));
|
|
var next = list("owner", 1L, "conv", page.nextCursor(), 1);
|
|
assertEquals(id - 1, next.items().getFirst().id());
|
|
assertNull(next.nextCursor());
|
|
assertThrows(MateClawException.class, () -> list("owner", 1L, "conv", "invalid-cursor", 1));
|
|
}
|
|
|
|
@Test void canonicalTeamTranscriptReaderCanQueryWorkerEvidence() {
|
|
when(teams.canReadTranscript("conv", null, null, "reviewer")).thenReturn(true);
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull())).thenReturn(List.of(evidence(id)));
|
|
assertEquals(1, list("reviewer", 1L, "conv", null, null).items().size());
|
|
}
|
|
|
|
@Test void jsonUsesStringIdentifiersAndMissingArtifactsAreUnavailable() throws Exception {
|
|
var builder = Jackson2ObjectMapperBuilder.json();
|
|
new JacksonConfig().longToStringCustomizer().customize(builder);
|
|
var mapper = builder.build();
|
|
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
|
var json = mapper.readTree(mapper.writeValueAsString(queries.detail("owner", 1L, id)));
|
|
assertTrue(json.get("id").isTextual());
|
|
assertEquals(Long.toString(id), json.get("id").asText());
|
|
var artifact = new EvidenceObservation("artifact:missing", EvidenceKind.ARTIFACT_SNAPSHOT,
|
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
|
null, "missing", "digest", "file metadata", null, now, now.minusSeconds(1));
|
|
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact)));
|
|
var unavailable = queries.detail("owner", 1L, id);
|
|
assertEquals("UNAVAILABLE", unavailable.validity());
|
|
assertNull(unavailable.artifactRef());
|
|
assertNull(unavailable.artifactDigest());
|
|
verifyNoInteractions(files);
|
|
}
|
|
|
|
@Test void emptyPageDoesNotLoadAttempts() {
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull()))
|
|
.thenReturn(List.of());
|
|
assertTrue(list("owner", 1L, "conv", null, null).items().isEmpty());
|
|
verify(store, never()).findAttempts(any(), any(), any());
|
|
verify(store, never()).findAttempt(any());
|
|
}
|
|
|
|
@Test void repeatedAttemptIsLoadedOnceAndLookaheadIsExcluded() {
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(3), isNull(), isNull()))
|
|
.thenReturn(List.of(evidence(id), evidence(id - 1),
|
|
new ExecutionEvidence(id - 2, 1L, 999L, "conv", evidence(id).observation())));
|
|
var page = list("owner", 1L, "conv", null, 2);
|
|
assertEquals(List.of(id, id - 1), page.items().stream().map(ExecutionEvidenceQueryService.View::id).toList());
|
|
assertNotNull(page.nextCursor());
|
|
verify(store).findAttempts(1L, "conv", List.of(1L));
|
|
verify(store, never()).findAttempt(any());
|
|
}
|
|
|
|
@Test void missingOrMismatchedBatchAttemptFailsClosed() {
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull()))
|
|
.thenReturn(List.of(evidence(id)));
|
|
when(store.findAttempts(1L, "conv", List.of(1L))).thenReturn(Map.of());
|
|
assertEquals(404, assertThrows(MateClawException.class,
|
|
() -> list("owner", 1L, "conv", null, null)).getCode());
|
|
var foreign = new ExecutionAttempt(1L, new ExecutionIdentity(2L, "other", "native", null,
|
|
"call", "call", 1, "provider", "tool", null, null, null, null, null, null, "fence"),
|
|
AttemptState.SUCCEEDED, EffectOutcome.UNCERTAIN, now, now);
|
|
when(store.findAttempts(1L, "conv", List.of(1L))).thenReturn(Map.of(1L, foreign));
|
|
assertEquals(404, assertThrows(MateClawException.class,
|
|
() -> list("owner", 1L, "conv", null, null)).getCode());
|
|
}
|
|
|
|
@Test void detailDetectsChangedPersistedArtifact(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception {
|
|
var cache = new GeneratedFileCache(root);
|
|
byte[] bytes = "original report".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
String artifactId = cache.put(bytes, "report.txt", "text/plain", new GeneratedFileCache.Owner(1L, 1L, "conv"));
|
|
String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
|
var observation = new EvidenceObservation("artifact:" + artifactId, EvidenceKind.ARTIFACT_SNAPSHOT,
|
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
|
null, artifactId, digest, "registered file", null, Instant.now(), Instant.now().plusSeconds(3600));
|
|
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation)));
|
|
var auth = mock(AuthService.class);
|
|
var admin = new vip.mate.auth.model.UserEntity();
|
|
admin.setId(1L); admin.setRole("admin");
|
|
when(auth.findByUsername("owner")).thenReturn(admin);
|
|
var properties = new ExecutionEvidenceProperties();
|
|
queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth,
|
|
mock(WorkspaceService.class), properties, new SimpleMeterRegistry());
|
|
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
|
|
java.nio.file.Files.writeString(root.resolve(artifactId), "externally replaced");
|
|
assertEquals("STALE", queries.detail("owner", 1L, id).validity());
|
|
when(store.list(eq(1L), eq("conv"), isNull(), isNull(), eq(21), isNull(), isNull()))
|
|
.thenReturn(List.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation)));
|
|
assertEquals("UNKNOWN", list("owner", 1L, "conv", null, 20).items().getFirst().validity(),
|
|
"pagination must remain metadata-only");
|
|
properties.setArtifactVersionCheckMaxBytes(0);
|
|
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
|
|
}
|
|
|
|
@Test void jsonChecksAreSourceAuthorizedAndDoNotInspectUnavailableFiles() {
|
|
when(store.findById(id)).thenReturn(Optional.of(evidence(id)));
|
|
assertThrows(MateClawException.class, () -> queries.checkJson("stranger", 1L, id, List.of("report")));
|
|
assertThrows(MateClawException.class, () -> queries.checkJson("owner", 2L, id, List.of("report")));
|
|
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
|
var artifact = new EvidenceObservation("artifact:private", EvidenceKind.ARTIFACT_SNAPSHOT,
|
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
|
null, "private", "digest", "private metadata", null, now, null);
|
|
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", artifact)));
|
|
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
|
verifyNoInteractions(files);
|
|
}
|
|
|
|
@Test void jsonCheckUsesRealDurableBytesWithoutPromotingEvidence(@org.junit.jupiter.api.io.TempDir java.nio.file.Path root) throws Exception {
|
|
var cache = new GeneratedFileCache(root);
|
|
byte[] bytes = "{\"report\":true}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
String artifactId = cache.put(bytes, "report.json", "application/json", new GeneratedFileCache.Owner(1L, 1L, "conv"));
|
|
String digest = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
|
var observation = new EvidenceObservation("artifact:" + artifactId, EvidenceKind.ARTIFACT_SNAPSHOT,
|
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null,
|
|
null, artifactId, digest, "registered file", null, Instant.now(), Instant.now().plusSeconds(3600));
|
|
when(store.findById(id)).thenReturn(Optional.of(new ExecutionEvidence(id, 1L, 1L, "conv", observation)));
|
|
var auth = mock(AuthService.class);
|
|
var user = new vip.mate.auth.model.UserEntity(); user.setId(1L); user.setRole("user");
|
|
when(auth.findByUsername("owner")).thenReturn(user);
|
|
var workspaces = mock(WorkspaceService.class);
|
|
when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(true);
|
|
var properties = new ExecutionEvidenceProperties();
|
|
queries = new ExecutionEvidenceQueryService(store, conversations, teams, cache, auth,
|
|
workspaces, properties, new SimpleMeterRegistry());
|
|
var result = queries.checkJson("owner", 1L, id, List.of("report"));
|
|
assertEquals("MATCH", result.status());
|
|
assertFalse(result.acceptanceEligible());
|
|
assertEquals("UNKNOWN", queries.detail("owner", 1L, id).validity());
|
|
assertEquals("MISSING_FIELDS", queries.checkJson("owner", 1L, id, List.of("appendix")).status());
|
|
properties.setArtifactVersionCheckMaxBytes(0);
|
|
assertEquals("UNKNOWN", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
|
properties.setArtifactVersionCheckMaxBytes(1024);
|
|
java.nio.file.Files.writeString(root.resolve(artifactId), "{\"report\":false}");
|
|
assertEquals("STALE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
|
when(workspaces.hasPermissionCached(1L, 1L, "viewer")).thenReturn(false);
|
|
assertEquals("UNAVAILABLE", queries.checkJson("owner", 1L, id, List.of("report")).status());
|
|
}
|
|
|
|
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
|
|
return queries.list(user, workspace, conversation, cursor, limit, null, null);
|
|
}
|
|
|
|
private ExecutionEvidence evidence(long evidenceId) {
|
|
return new ExecutionEvidence(evidenceId, 1L, 1L, "conv", new EvidenceObservation("callback", EvidenceKind.TOOL_RETURNED,
|
|
EvidenceResult.OBSERVED, SourceLevel.PLATFORM_OBSERVED, null, null, null, null, null, null,
|
|
null, null, "Tool callback returned", null, now, null));
|
|
}
|
|
}
|