mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
feat(evidence): inspect artifact version changes on demand
This commit is contained in:
parent
b04b0f673b
commit
3843dc04b7
@ -9,6 +9,9 @@ public class ExecutionEvidenceProperties {
|
||||
public enum Mode { OFF, OBSERVE, ENFORCE }
|
||||
private Mode mode = Mode.OBSERVE;
|
||||
private int retentionDays = 90;
|
||||
private int artifactVersionCheckMaxBytes = 1_048_576;
|
||||
public int getArtifactVersionCheckMaxBytes() { return artifactVersionCheckMaxBytes; }
|
||||
public void setArtifactVersionCheckMaxBytes(int value) { artifactVersionCheckMaxBytes = Math.clamp(value, 0, 16_777_216); }
|
||||
private int cleanupMaxBatches = 10;
|
||||
public int getCleanupMaxBatches() { return cleanupMaxBatches; }
|
||||
public void setCleanupMaxBatches(int value) { cleanupMaxBatches = Math.clamp(value, 1, 100); }
|
||||
|
||||
@ -68,7 +68,7 @@ public class ExecutionEvidenceQueryService {
|
||||
if (page.isEmpty()) return new Page(List.of(), null);
|
||||
var attempts = store.findAttempts(canonicalWorkspace, conversationId,
|
||||
page.stream().map(ExecutionEvidence::attemptId).distinct().toList());
|
||||
return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()))).toList(),
|
||||
return new Page(page.stream().map(row -> view(username, row, attempts.get(row.attemptId()), false)).toList(),
|
||||
hasMore ? encode(page.getLast()) : null);
|
||||
} finally {
|
||||
metrics.timer("mateclaw.execution.evidence.query.latency").record(
|
||||
@ -81,7 +81,7 @@ public class ExecutionEvidenceQueryService {
|
||||
ExecutionEvidence row = store.findById(id).orElseThrow(this::hidden);
|
||||
Long canonicalWorkspace = authorize(username, workspaceId, row.conversationId());
|
||||
if (!canonicalWorkspace.equals(row.workspaceId())) throw hidden();
|
||||
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden));
|
||||
return view(username, row, store.findAttempt(row.attemptId()).orElseThrow(this::hidden), true);
|
||||
}
|
||||
|
||||
private Long authorize(String username, Long workspaceId, String conversationId) {
|
||||
@ -94,7 +94,7 @@ public class ExecutionEvidenceQueryService {
|
||||
return conversation.getWorkspaceId();
|
||||
}
|
||||
|
||||
private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt) {
|
||||
private View view(String username, ExecutionEvidence row, ExecutionAttempt attempt, boolean inspectVersion) {
|
||||
if (attempt == null || !Objects.equals(attempt.id(), row.attemptId())) throw hidden();
|
||||
if (!Objects.equals(attempt.identity().workspaceId(), row.workspaceId())
|
||||
|| !Objects.equals(attempt.identity().conversationId(), row.conversationId())) throw hidden();
|
||||
@ -116,6 +116,16 @@ public class ExecutionEvidenceQueryService {
|
||||
} else if (!files.isDurablyAvailable(artifact, row.workspaceId(), row.conversationId())) {
|
||||
validity = "UNAVAILABLE";
|
||||
artifact = null;
|
||||
} else if (inspectVersion && !"UNAVAILABLE".equals(validity)) {
|
||||
var version = files.probeDurableArtifactVersion(artifact, row.workspaceId(), row.conversationId(),
|
||||
digest, properties.getArtifactVersionCheckMaxBytes());
|
||||
if (version == GeneratedFileCache.ArtifactVersion.CHANGED) {
|
||||
validity = "STALE";
|
||||
} else if (version == GeneratedFileCache.ArtifactVersion.UNAVAILABLE) {
|
||||
validity = "UNAVAILABLE";
|
||||
artifact = null;
|
||||
}
|
||||
// Equality/budget exhaustion remains UNKNOWN; there is no managed generation fence.
|
||||
}
|
||||
}
|
||||
metrics.counter("mateclaw.execution.evidence.validity", "status", validity).increment();
|
||||
|
||||
@ -21,6 +21,8 @@ import java.time.Instant;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
@ -396,23 +398,74 @@ public class GeneratedFileCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** No positive verification state: a matching shared file is still unverified. */
|
||||
public enum ArtifactVersion { UNVERIFIED, CHANGED, UNAVAILABLE }
|
||||
|
||||
/** Bounded metadata-only probe. Availability does not imply that current content is verified. */
|
||||
public boolean isDurablyAvailable(String id, Long workspaceId, String conversationId) {
|
||||
if (id == null || !ID_RE.matcher(id).matches()) return false;
|
||||
Path bin = storageDir.resolve(id).normalize();
|
||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||
try {
|
||||
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin)
|
||||
|| !Files.isRegularFile(meta) || Files.size(meta) > 16_384) return false;
|
||||
Metadata stored = parseMeta(Files.readString(meta), id);
|
||||
return stored.expireAt() > System.currentTimeMillis()
|
||||
&& Objects.equals(workspaceId, stored.workspaceId())
|
||||
&& Objects.equals(conversationId, stored.conversationId());
|
||||
} catch (Exception unavailable) {
|
||||
return availableMetadata(id, workspaceId, conversationId) != null;
|
||||
} catch (IOException | RuntimeException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand bounded comparison with a historical snapshot, never a freshness certificate.
|
||||
* A changed digest is useful negative evidence; equality cannot exclude concurrent writers.
|
||||
*/
|
||||
public ArtifactVersion probeDurableArtifactVersion(String id, Long workspaceId, String conversationId,
|
||||
String expectedDigest, int maxBytes) {
|
||||
try {
|
||||
Metadata before = availableMetadata(id, workspaceId, conversationId);
|
||||
if (before == null) return ArtifactVersion.UNAVAILABLE;
|
||||
int budget = Math.clamp(maxBytes, 0, 16_777_216);
|
||||
if (budget == 0 || expectedDigest == null || !expectedDigest.matches("[0-9a-fA-F]{64}")) {
|
||||
return ArtifactVersion.UNVERIFIED;
|
||||
}
|
||||
Path bin = storageDir.resolve(id);
|
||||
if (Files.size(bin) > budget) return ArtifactVersion.UNVERIFIED;
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
int total = 0;
|
||||
byte[] buffer = new byte[8192];
|
||||
try (var input = Files.newInputStream(bin, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
int read;
|
||||
// At most budget+1 bytes even if a concurrent writer grows the file.
|
||||
while ((read = input.read(buffer, 0, Math.min(buffer.length, budget + 1 - total))) != -1) {
|
||||
total += read;
|
||||
if (total > budget) return ArtifactVersion.UNVERIFIED;
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
Metadata after = availableMetadata(id, workspaceId, conversationId);
|
||||
if (after == null) return ArtifactVersion.UNAVAILABLE;
|
||||
if (!before.equals(after)) return ArtifactVersion.UNVERIFIED;
|
||||
return expectedDigest.equalsIgnoreCase(HexFormat.of().formatHex(digest.digest()))
|
||||
? ArtifactVersion.UNVERIFIED : ArtifactVersion.CHANGED;
|
||||
} catch (IOException | RuntimeException unavailable) {
|
||||
return ArtifactVersion.UNAVAILABLE;
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private Metadata availableMetadata(String id, Long workspaceId, String conversationId) throws IOException {
|
||||
if (id == null || !ID_RE.matcher(id).matches()) return null;
|
||||
Path bin = storageDir.resolve(id).normalize();
|
||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||
if (!bin.startsWith(storageDir) || !Files.isRegularFile(bin, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !Files.isRegularFile(meta, LinkOption.NOFOLLOW_LINKS)) return null;
|
||||
byte[] raw;
|
||||
try (var input = Files.newInputStream(meta, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
raw = input.readNBytes(16_385);
|
||||
}
|
||||
if (raw.length > 16_384) return null;
|
||||
Metadata stored = parseMeta(new String(raw, StandardCharsets.UTF_8), id);
|
||||
return stored.expireAt() > System.currentTimeMillis()
|
||||
&& Objects.equals(workspaceId, stored.workspaceId())
|
||||
&& Objects.equals(conversationId, stored.conversationId()) ? stored : null;
|
||||
}
|
||||
|
||||
private Entry loadFromDisk(String id) {
|
||||
Path bin = storageDir.resolve(id).normalize();
|
||||
Path meta = storageDir.resolve(id + META_SUFFIX).normalize();
|
||||
|
||||
@ -134,6 +134,8 @@ mateclaw:
|
||||
execution-evidence:
|
||||
# Observe receipts only. Enforcement requires managed verification scopes and is not available yet.
|
||||
mode: observe
|
||||
# On-demand detail probe only; matching bytes remain UNKNOWN without managed scopes. 0 disables.
|
||||
artifact-version-check-max-bytes: 1048576
|
||||
retention-days: 90
|
||||
max-summary-bytes: 2048
|
||||
max-observations: 32
|
||||
|
||||
@ -100,6 +100,7 @@ class ExecutionEvidenceQueryTest {
|
||||
assertEquals("UNAVAILABLE", unavailable.validity());
|
||||
assertNull(unavailable.artifactRef());
|
||||
assertNull(unavailable.artifactDigest());
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void emptyPageDoesNotLoadAttempts() {
|
||||
@ -135,6 +136,33 @@ class ExecutionEvidenceQueryTest {
|
||||
() -> 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());
|
||||
}
|
||||
|
||||
private ExecutionEvidenceQueryService.Page list(String user, Long workspace, String conversation, String cursor, Integer limit) {
|
||||
return queries.list(user, workspace, conversation, cursor, limit, null, null);
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package vip.mate.tool.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static vip.mate.tool.document.GeneratedFileCache.ArtifactVersion.*;
|
||||
|
||||
class GeneratedFileArtifactVersionTest {
|
||||
@TempDir Path root;
|
||||
|
||||
private String put(GeneratedFileCache cache, String text) {
|
||||
return cache.put(text.getBytes(StandardCharsets.UTF_8), "report.txt", "text/plain",
|
||||
new GeneratedFileCache.Owner(1L, 1L, "conv"));
|
||||
}
|
||||
private String digest(String text) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test void matchingBytesRemainUnverifiedAndChangedBytesAreDetected() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String id = put(cache, "report");
|
||||
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
Files.writeString(root.resolve(id), "different report");
|
||||
assertEquals(CHANGED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
assertEquals("report", new String(cache.get(id).orElseThrow().bytes(), StandardCharsets.UTF_8),
|
||||
"probe reads durable bytes, not the old hot cache");
|
||||
}
|
||||
|
||||
@Test void overBudgetDisabledAndInvalidDigestNeverPassOrClaimChange() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String id = put(cache, "report");
|
||||
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 2));
|
||||
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("different"), 0));
|
||||
assertEquals(UNVERIFIED, cache.probeDurableArtifactVersion(id, 1L, "conv", "bad digest", 1024));
|
||||
}
|
||||
|
||||
@Test void missingForeignExpiredAndOversizedMetadataAreUnavailable() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String id = put(cache, "report");
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 2L, "conv", digest("report"), 1024));
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "other", digest("report"), 1024));
|
||||
Path meta = root.resolve(id + ".meta");
|
||||
String original = Files.readString(meta);
|
||||
Files.writeString(meta, "0" + original.substring(original.indexOf('\t')));
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
Files.writeString(meta, "x".repeat(16_385));
|
||||
assertFalse(cache.isDurablyAvailable(id, 1L, "conv"));
|
||||
Files.writeString(meta, original);
|
||||
Files.delete(root.resolve(id));
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.condition.EnabledOnOs({org.junit.jupiter.api.condition.OS.LINUX, org.junit.jupiter.api.condition.OS.MAC})
|
||||
@Test void symbolicLinksAreNotFollowedByTheProbe() throws Exception {
|
||||
var cache = new GeneratedFileCache(root);
|
||||
String id = put(cache, "report");
|
||||
Path outside = Files.writeString(root.resolve("outside"), "report");
|
||||
Files.delete(root.resolve(id));
|
||||
Files.createSymbolicLink(root.resolve(id), outside);
|
||||
assertFalse(cache.isDurablyAvailable(id, 1L, "conv"));
|
||||
assertEquals(UNAVAILABLE, cache.probeDurableArtifactVersion(id, 1L, "conv", digest("report"), 1024));
|
||||
}
|
||||
}
|
||||
@ -11,11 +11,15 @@ const loading = ref(false)
|
||||
const errorKey = ref('')
|
||||
const items = ref<ExecutionEvidence[]>([])
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const detailLoading = ref<Record<string, boolean>>({})
|
||||
const detailErrors = ref<Record<string, string>>({})
|
||||
let generation = 0
|
||||
|
||||
async function load(more = false) {
|
||||
if (loading.value || !props.conversationId) return
|
||||
const request = ++generation
|
||||
detailLoading.value = {}
|
||||
detailErrors.value = {}
|
||||
loading.value = true
|
||||
errorKey.value = ''
|
||||
try {
|
||||
@ -38,6 +42,29 @@ async function load(more = false) {
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadDetail(id: string, event: Event) {
|
||||
if (!(event.target as HTMLDetailsElement).open || detailLoading.value[id]) return
|
||||
const request = generation
|
||||
detailLoading.value[id] = true
|
||||
delete detailErrors.value[id]
|
||||
try {
|
||||
const { data } = await executionEvidenceApi.get(id)
|
||||
if (request !== generation) return
|
||||
items.value = items.value.map(item => item.id === id ? data : item)
|
||||
} catch (error) {
|
||||
if (request !== generation) return
|
||||
const failure = error as { code?: number; response?: { status?: number } }
|
||||
const code = failure.response?.status ?? failure.code
|
||||
if (code === 401 || code === 403 || code === 404) {
|
||||
items.value = items.value.filter(item => item.id !== id)
|
||||
errorKey.value = 'executionEvidence.accessError'
|
||||
} else {
|
||||
detailErrors.value[id] = 'executionEvidence.loadError'
|
||||
}
|
||||
} finally {
|
||||
if (request === generation) delete detailLoading.value[id]
|
||||
}
|
||||
}
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value
|
||||
if (expanded.value && !loaded.value) void load()
|
||||
@ -45,6 +72,8 @@ function toggle() {
|
||||
watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
|
||||
generation++
|
||||
items.value = []
|
||||
detailLoading.value = {}
|
||||
detailErrors.value = {}
|
||||
nextCursor.value = null
|
||||
errorKey.value = ''
|
||||
loaded.value = false
|
||||
@ -80,8 +109,10 @@ onBeforeUnmount(() => { generation++ })
|
||||
<div><dt>{{ t('executionEvidence.observedAt') }}</dt><dd><time :datetime="item.observedAt">{{ item.observedAt }}</time></dd></div>
|
||||
<div v-if="item.expiresAt"><dt>{{ t('executionEvidence.expiresAt') }}</dt><dd><time :datetime="item.expiresAt">{{ item.expiresAt }}</time></dd></div>
|
||||
</dl>
|
||||
<details>
|
||||
<details @toggle="loadDetail(item.id, $event)" :aria-busy="!!detailLoading[item.id]">
|
||||
<summary>{{ t('executionEvidence.details') }}</summary>
|
||||
<p v-if="detailLoading[item.id]" role="status">{{ t('common.loading') }}</p>
|
||||
<p v-if="detailErrors[item.id]" role="alert">{{ t(detailErrors[item.id]) }}</p>
|
||||
<dl>
|
||||
<div><dt>{{ t('executionEvidence.id') }}</dt><dd>{{ item.id }}</dd></div>
|
||||
<div><dt>{{ t('executionEvidence.attemptId') }}</dt><dd>{{ item.attemptId }}</dd></div>
|
||||
|
||||
@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ExecutionEvidenceList from '../ExecutionEvidenceList.vue'
|
||||
import { executionEvidenceApi } from '@/api/executionEvidence'
|
||||
import en from '@/i18n/locales/en-US'
|
||||
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn() } }))
|
||||
vi.mock('@/api/executionEvidence', () => ({ executionEvidenceApi: { list: vi.fn(), get: vi.fn() } }))
|
||||
const apps: ReturnType<typeof createApp>[] = []
|
||||
const row = (id: string) => ({ id, attemptId: '9223372036854775801', conversationId: 'one', toolName: 'execCommand', state: 'SUCCEEDED', effectOutcome: 'CONFIRMED', kind: 'COMMAND_EXIT', result: 'OBSERVED', sourceLevel: 'RUNTIME', validity: 'UNKNOWN', summary: 'Exit code: 0', observedAt: '2026-09-07T12:00:00Z', expiresAt: null, artifactRef: null, artifactDigest: null })
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
@ -42,4 +42,34 @@ describe('execution evidence', () => {
|
||||
resolve({ data: { items: [row('old')], nextCursor: null } }); await flush()
|
||||
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
|
||||
})
|
||||
it('refreshes the selected detail lazily and displays a changed artifact as stale', async () => {
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('artifact')], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.get).mockResolvedValue({ data: { ...row('artifact'), validity: 'STALE' } } as never)
|
||||
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
expect(executionEvidenceApi.get).not.toHaveBeenCalled()
|
||||
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
|
||||
expect(executionEvidenceApi.get).toHaveBeenCalledWith('artifact')
|
||||
expect(host.textContent).toContain(en.executionEvidence.validity.STALE)
|
||||
})
|
||||
it('drops a detail response after the conversation changes', async () => {
|
||||
let resolve!: (value: unknown) => void
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValueOnce({ data: { items: [row('old')], nextCursor: null } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [row('new')], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.get).mockImplementationOnce(() => new Promise(r => { resolve = r }) as never)
|
||||
const { host, props } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
|
||||
props.conversationId = 'two'; await flush()
|
||||
resolve({ data: { ...row('old'), summary: 'stale sensitive detail' } }); await flush()
|
||||
expect(host.textContent).not.toContain('stale sensitive detail')
|
||||
expect(host.querySelector('[data-evidence-item]')?.getAttribute('data-evidence-item')).toBe('new')
|
||||
})
|
||||
it('removes the old row when detail authorization is revoked', async () => {
|
||||
vi.mocked(executionEvidenceApi.list).mockResolvedValue({ data: { items: [row('old')], nextCursor: null } } as never)
|
||||
vi.mocked(executionEvidenceApi.get).mockRejectedValue({ code: 404 })
|
||||
const { host } = mount(); host.querySelector<HTMLButtonElement>('[data-evidence-toggle]')!.click(); await flush()
|
||||
const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); await flush()
|
||||
expect(host.querySelectorAll('[data-evidence-item]')).toHaveLength(0)
|
||||
expect(host.querySelector('[role="alert"]')?.textContent).toContain('permission')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user