Files
viettranx c7b2df9e34 feat(vault): chat_id isolation for isolated teams
- Add vault_documents.chat_id + composite index (migration 000056)
- Filter vault_search by chat_id when team.workspace_scope=isolated
- Stamp chat_id on AfterWrite/AfterWriteMedia for isolated teams
- Deny cross-chat vault_read in isolated teams (M2 fix)
- RunContext.TeamIsolated flag resolved once per run
- Fallback WorkspaceChatID → ChatID in loop_context for entry points
  that don't set WorkspaceChatID explicitly (WS direct, HTTP, cron)

Fixes cross-chat doc leak where agent in chat A could see vault docs
from chat B within the same isolated team.
2026-04-23 18:43:31 +07:00

920 lines
30 KiB
Go

package tools
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// fakeVaultStore embeds store.VaultStore (nil) so the struct satisfies the
// interface at compile time. Methods used by vault_read.Execute are
// implemented; others would nil-panic if called.
type fakeVaultStore struct {
store.VaultStore
byID map[string]*store.VaultDocument // key: tenantID + ":" + docID
outlinks map[string][]store.VaultLink // key: tenantID + ":" + docID
outLinkErr error // injected error for GetOutLinks
targetsErr error // injected error for GetDocumentsByIDs
}
func (f *fakeVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string) (*store.VaultDocument, error) {
if f.byID == nil {
return nil, os.ErrNotExist
}
doc, ok := f.byID[tenantID+":"+id]
if !ok {
return nil, os.ErrNotExist
}
return doc, nil
}
func (f *fakeVaultStore) GetOutLinks(ctx context.Context, tenantID, docID string) ([]store.VaultLink, error) {
if f.outLinkErr != nil {
return nil, f.outLinkErr
}
if f.outlinks == nil {
return nil, nil
}
return f.outlinks[tenantID+":"+docID], nil
}
func (f *fakeVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID string, docIDs []string) ([]store.VaultDocument, error) {
if f.targetsErr != nil {
return nil, f.targetsErr
}
out := make([]store.VaultDocument, 0, len(docIDs))
for _, id := range docIDs {
if d, ok := f.byID[tenantID+":"+id]; ok {
out = append(out, *d)
}
}
return out, nil
}
// newVaultReadTestTool builds a VaultReadTool with a temp workspace and a
// fake store pre-seeded with docs.
func newVaultReadTestTool(t *testing.T, docs ...*store.VaultDocument) (*VaultReadTool, string) {
t.Helper()
ws := t.TempDir()
fake := &fakeVaultStore{byID: make(map[string]*store.VaultDocument)}
for _, d := range docs {
fake.byID[d.TenantID+":"+d.ID] = d
}
tool := NewVaultReadTool()
tool.SetVaultStore(fake)
tool.SetWorkspace(ws)
return tool, ws
}
// writeFile creates a file under workspace with the given relative path.
func writeFile(t *testing.T, ws, rel, content string) {
t.Helper()
full := filepath.Join(ws, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
// writeBytes creates a file with raw bytes (for binary content tests).
func writeBytes(t *testing.T, ws, rel string, b []byte) {
t.Helper()
full := filepath.Join(ws, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, b, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
func makeCtx(tenantID, agentID uuid.UUID) context.Context {
ctx := context.Background()
if tenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, tenantID)
}
if agentID != uuid.Nil {
ctx = store.WithAgentID(ctx, agentID)
}
return ctx
}
func makeCtxWithTeam(tenantID, agentID uuid.UUID, teamID string) context.Context {
ctx := makeCtx(tenantID, agentID)
return store.WithRunContext(ctx, &store.RunContext{
TenantID: tenantID,
AgentID: agentID,
TeamID: teamID,
})
}
// --- 1. shared scope → allow, content returned. ---
func TestVaultRead_SharedScope_Allow(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "shared/notes.md", Title: "Notes",
DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "shared/notes.md", "hello world")
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "hello world") {
t.Fatalf("content missing: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "Notes") {
t.Fatalf("title missing: %s", res.ForLLM)
}
}
// --- 2. personal scope, AgentID matches ctx → allow. ---
func TestVaultRead_PersonalScope_Match_Allow(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
aid := agentID.String()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
AgentID: &aid, Scope: "personal", Path: "memo.md",
Title: "Memo", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "memo.md", "personal body")
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "personal body") {
t.Fatalf("content missing: %s", res.ForLLM)
}
}
// --- 3. personal scope, AgentID mismatch → deny. ---
func TestVaultRead_PersonalScope_Mismatch_Deny(t *testing.T) {
tenantID := uuid.New()
agentA := uuid.New()
agentB := uuid.New()
docID := uuid.New()
aid := agentA.String()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
AgentID: &aid, Scope: "personal", Path: "memo.md",
Title: "Memo", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "memo.md", "personal body")
res := tool.Execute(makeCtx(tenantID, agentB),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "not accessible") {
t.Fatalf("expected access denied, got: %s", res.ForLLM)
}
}
// --- 4. team scope, RunContext.TeamID matches → allow. ---
func TestVaultRead_TeamScope_Match_Allow(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
teamID := uuid.New().String()
tid := teamID
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
TeamID: &tid, Scope: "team", Path: "team/doc.md",
Title: "Team Doc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "team/doc.md", "team body")
res := tool.Execute(makeCtxWithTeam(tenantID, agentID, teamID),
map[string]any{"doc_id": docID.String()})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "team body") {
t.Fatalf("content missing: %s", res.ForLLM)
}
}
// --- 5. team scope, no run-context / mismatch → deny. ---
func TestVaultRead_TeamScope_NoContext_Deny(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
tid := uuid.New().String()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
TeamID: &tid, Scope: "team", Path: "team/doc.md",
Title: "Team Doc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "team/doc.md", "team body")
// no team in ctx.
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError {
t.Fatalf("expected deny, got: %s", res.ForLLM)
}
// mismatched team in ctx.
res2 := tool.Execute(makeCtxWithTeam(tenantID, agentID, uuid.New().String()),
map[string]any{"doc_id": docID.String()})
if !res2.IsError {
t.Fatalf("expected deny on mismatch, got: %s", res2.ForLLM)
}
}
// --- 5a. isolated team, cross-chat doc → deny. ---
func TestVaultRead_TeamScope_IsolatedCrossChat_Deny(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
teamID := uuid.New().String()
tid := teamID
chatA := "chatA"
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
TeamID: &tid, ChatID: &chatA,
Scope: "team", Path: "team/doc.md",
Title: "Team Doc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "team/doc.md", "team body")
// Caller bound to chatB in isolated team → deny.
ctx := store.WithRunContext(
makeCtx(tenantID, agentID),
&store.RunContext{
TenantID: tenantID, AgentID: agentID,
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatB",
})
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "not accessible") {
t.Fatalf("expected cross-chat deny, got: %s", res.ForLLM)
}
}
// --- 5b. isolated team, same-chat doc → allow. ---
func TestVaultRead_TeamScope_IsolatedSameChat_Allow(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
teamID := uuid.New().String()
tid := teamID
chatA := "chatA"
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
TeamID: &tid, ChatID: &chatA,
Scope: "team", Path: "team/doc.md",
Title: "Team Doc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "team/doc.md", "team body")
ctx := store.WithRunContext(
makeCtx(tenantID, agentID),
&store.RunContext{
TenantID: tenantID, AgentID: agentID,
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatA",
})
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
if res.IsError {
t.Fatalf("expected allow for same-chat, got error: %s", res.ForLLM)
}
}
// --- 5c. isolated team, team-wide doc (chat_id NULL) → allow regardless of chat. ---
func TestVaultRead_TeamScope_IsolatedTeamWide_Allow(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
teamID := uuid.New().String()
tid := teamID
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
TeamID: &tid, ChatID: nil, // team-wide
Scope: "team", Path: "team/doc.md",
Title: "Team Doc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "team/doc.md", "team body")
ctx := store.WithRunContext(
makeCtx(tenantID, agentID),
&store.RunContext{
TenantID: tenantID, AgentID: agentID,
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatZ",
})
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
if res.IsError {
t.Fatalf("team-wide doc should be accessible in isolated team, got: %s", res.ForLLM)
}
}
// --- 6. cross-tenant (different tenant in ctx) → not-found. ---
func TestVaultRead_CrossTenant_NotFound(t *testing.T) {
tenantA := uuid.New()
tenantB := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantA.String(),
Scope: "shared", Path: "a.md", Title: "A", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "a.md", "body")
res := tool.Execute(makeCtx(tenantB, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "not found") {
t.Fatalf("expected not found, got: %s", res.ForLLM)
}
}
// --- 7. missing doc id → not-found. ---
func TestVaultRead_MissingDoc_NotFound(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
tool, _ := newVaultReadTestTool(t)
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": uuid.New().String()})
if !res.IsError || !strings.Contains(res.ForLLM, "not found") {
t.Fatalf("expected not found, got: %s", res.ForLLM)
}
}
// --- 8. invalid UUID → arg error. ---
func TestVaultRead_InvalidUUID(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
tool, _ := newVaultReadTestTool(t)
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": "not-a-uuid"})
if !res.IsError || !strings.Contains(res.ForLLM, "invalid doc_id") {
t.Fatalf("expected invalid doc_id error, got: %s", res.ForLLM)
}
}
// --- 9. media DocType → rejected with hint. ---
func TestVaultRead_MediaDocType_Rejected(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "pic.png", Title: "Pic", DocType: "media",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "pic.png", "pretend-png")
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "read_image") {
t.Fatalf("expected media-handler hint, got: %s", res.ForLLM)
}
}
// --- 10. binary extension even when DocType!=media → rejected by blocklist. ---
func TestVaultRead_BinaryExtension_Rejected(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "report.PDF", Title: "Rep", DocType: "document",
}
tool, ws := newVaultReadTestTool(t, doc)
writeFile(t, ws, "report.PDF", "%PDF-1.7")
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, ".pdf") {
t.Fatalf("expected pdf blocklist error, got: %s", res.ForLLM)
}
}
// --- 11. text extension but binary bytes → rejected by UTF-8 sniff. ---
func TestVaultRead_BinaryContent_UTF8Rejected(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "blob.txt", Title: "Blob", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
// Invalid UTF-8: stray 0xC3 byte with no continuation.
writeBytes(t, ws, "blob.txt", []byte{0xC3, 0x28, 0xFF, 0xFE, 0xFD})
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "UTF-8") {
t.Fatalf("expected UTF-8 rejection, got: %s", res.ForLLM)
}
}
// --- 12. oversize file → truncated with marker. ---
func TestVaultRead_Oversize_Truncated(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "big.md", Title: "Big", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
// 30KB of 'a' — fits in UTF-8 sniff check, but larger than max_bytes below.
big := strings.Repeat("a", 30_000)
writeFile(t, ws, "big.md", big)
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String(), "max_bytes": float64(10_000)})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "truncated") {
t.Fatalf("expected truncation marker, got: %s", res.ForLLM)
}
}
// --- 13. symlink escaping workspace → denied. ---
func TestVaultRead_SymlinkEscape_Denied(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "escape.md", Title: "Esc", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
// Create target outside workspace.
outside := t.TempDir()
target := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(target, []byte("leak"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
// Symlink ws/escape.md → outside/secret.txt.
if err := os.Symlink(target, filepath.Join(ws, "escape.md")); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": docID.String()})
if !res.IsError || !strings.Contains(res.ForLLM, "outside workspace") {
t.Fatalf("expected symlink escape denied, got: %s", res.ForLLM)
}
}
// --- Outlinks footer tests -------------------------------------------------
// sharedDoc returns a minimal shared-scope vault doc.
func sharedDoc(tenantID uuid.UUID, title, path string) *store.VaultDocument {
return &store.VaultDocument{
ID: uuid.New().String(), TenantID: tenantID.String(),
Scope: "shared", Path: path, Title: title, DocType: "note",
}
}
// personalDoc returns a personal-scope doc owned by agentID.
func personalDoc(tenantID, agentID uuid.UUID, title, path string) *store.VaultDocument {
aid := agentID.String()
return &store.VaultDocument{
ID: uuid.New().String(), TenantID: tenantID.String(),
AgentID: &aid, Scope: "personal", Path: path, Title: title, DocType: "note",
}
}
// teamDoc returns a team-scope doc bound to teamID.
func teamDoc(tenantID uuid.UUID, teamID, title, path string) *store.VaultDocument {
tid := teamID
return &store.VaultDocument{
ID: uuid.New().String(), TenantID: tenantID.String(),
TeamID: &tid, Scope: "team", Path: path, Title: title, DocType: "note",
}
}
// seedWithLinks extends the fake store's outlinks map.
func seedLinks(tool *VaultReadTool, tenantID, fromID string, links []store.VaultLink) {
f := tool.vaultStore.(*fakeVaultStore)
if f.outlinks == nil {
f.outlinks = make(map[string][]store.VaultLink)
}
f.outlinks[tenantID+":"+fromID] = links
}
// --- Case 1: outlinks present, all in scope → listed in order. ---
func TestVaultReadOutlinks_AllInScope_Listed(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tA := sharedDoc(tenantID, "Target A", "a.md")
tB := sharedDoc(tenantID, "Target B", "b.md")
tool, ws := newVaultReadTestTool(t, src, tA, tB)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: tA.ID, LinkType: "wikilink"},
{ToDocID: tB.ID, LinkType: "reference"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("missing Links heading: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "Target A — id: "+tA.ID+" (wikilink)") {
t.Fatalf("target A line missing/wrong: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "Target B — id: "+tB.ID+" (reference)") {
t.Fatalf("target B line missing/wrong: %s", res.ForLLM)
}
// Order preserved (A before B).
if strings.Index(res.ForLLM, "Target A") > strings.Index(res.ForLLM, "Target B") {
t.Fatalf("order not preserved: %s", res.ForLLM)
}
}
// --- Case 2: outlink to personal doc owned by another agent → dropped. ---
func TestVaultReadOutlinks_PersonalOtherAgent_Dropped(t *testing.T) {
tenantID := uuid.New()
agentSelf := uuid.New()
agentOther := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
other := personalDoc(tenantID, agentOther, "Other Memo", "other.md")
tool, ws := newVaultReadTestTool(t, src, other)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: other.ID, LinkType: "wikilink"},
})
res := tool.Execute(makeCtx(tenantID, agentSelf), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "Other Memo") {
t.Fatalf("leaked other-agent personal doc: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("empty Links heading should not appear: %s", res.ForLLM)
}
}
// --- Case 3: outlink to team doc from different team → dropped. ---
func TestVaultReadOutlinks_TeamOtherTeam_Dropped(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
teamSelf := uuid.New().String()
teamOther := uuid.New().String()
src := sharedDoc(tenantID, "Source", "src.md")
target := teamDoc(tenantID, teamOther, "Other Team Doc", "ot.md")
tool, ws := newVaultReadTestTool(t, src, target)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: target.ID, LinkType: "wikilink"},
})
res := tool.Execute(makeCtxWithTeam(tenantID, agentID, teamSelf),
map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "Other Team Doc") {
t.Fatalf("leaked other-team doc: %s", res.ForLLM)
}
}
// --- Case 4: outlink to shared doc → always included. ---
func TestVaultReadOutlinks_Shared_Included(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tgt := sharedDoc(tenantID, "Shared Target", "st.md")
tool, ws := newVaultReadTestTool(t, src, tgt)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: tgt.ID, LinkType: "wikilink"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "Shared Target") {
t.Fatalf("shared target missing: %s", res.ForLLM)
}
}
// --- Case 5: task_attachment link type → dropped by type filter. ---
func TestVaultReadOutlinks_TaskAttachment_Dropped(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tgt := sharedDoc(tenantID, "Task Target", "tk.md")
tool, ws := newVaultReadTestTool(t, src, tgt)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: tgt.ID, LinkType: "task_attachment"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("footer should be absent for task_attachment only: %s", res.ForLLM)
}
}
// --- Case 6: broken link (target doc deleted) → dropped silently. ---
func TestVaultReadOutlinks_BrokenLink_Dropped(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
ghostID := uuid.New().String()
tool, ws := newVaultReadTestTool(t, src) // ghost not seeded
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: ghostID, LinkType: "wikilink"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("footer should be absent when only broken links: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, ghostID) {
t.Fatalf("ghost id leaked: %s", res.ForLLM)
}
}
// --- Case 7: self-link → dropped. ---
func TestVaultReadOutlinks_SelfLink_Dropped(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tool, ws := newVaultReadTestTool(t, src)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: src.ID, LinkType: "wikilink"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("self-link should not produce footer: %s", res.ForLLM)
}
}
// --- Case 8: >20 valid links → capped with overflow marker. ---
func TestVaultReadOutlinks_Overflow_Capped(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
docs := []*store.VaultDocument{src}
links := []store.VaultLink{}
for i := range 25 {
d := sharedDoc(tenantID, fmt.Sprintf("T%02d", i), fmt.Sprintf("t%02d.md", i))
docs = append(docs, d)
links = append(links, store.VaultLink{ToDocID: d.ID, LinkType: "wikilink"})
}
tool, ws := newVaultReadTestTool(t, docs...)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, links)
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "…[5 more links omitted]") {
t.Fatalf("expected overflow marker for 25→20 cap: %s", res.ForLLM)
}
// Count rendered link lines (prefix "- T").
n := strings.Count(res.ForLLM, "\n- T")
if n != 20 {
t.Fatalf("expected 20 kept links, got %d", n)
}
}
// --- Case 9: zero valid links → no heading at all. ---
func TestVaultReadOutlinks_ZeroValid_NoHeading(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tool, ws := newVaultReadTestTool(t, src)
writeFile(t, ws, "src.md", "body")
// no links seeded.
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("should not emit empty heading: %s", res.ForLLM)
}
}
// --- Case 10: dedup same target via wikilink + reference → shown once. ---
func TestVaultReadOutlinks_Dedup_ByTarget(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tgt := sharedDoc(tenantID, "DupTarget", "dup.md")
tool, ws := newVaultReadTestTool(t, src, tgt)
writeFile(t, ws, "src.md", "body")
seedLinks(tool, tenantID.String(), src.ID, []store.VaultLink{
{ToDocID: tgt.ID, LinkType: "wikilink"},
{ToDocID: tgt.ID, LinkType: "reference"},
})
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if n := strings.Count(res.ForLLM, "DupTarget"); n != 1 {
t.Fatalf("expected 1 occurrence of DupTarget, got %d: %s", n, res.ForLLM)
}
// First link_type wins.
if !strings.Contains(res.ForLLM, "(wikilink)") {
t.Fatalf("expected first link_type wikilink to win: %s", res.ForLLM)
}
}
// --- Case: GetOutLinks error → read succeeds, footer empty. ---
func TestVaultReadOutlinks_StoreError_NoFooter(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
src := sharedDoc(tenantID, "Source", "src.md")
tool, ws := newVaultReadTestTool(t, src)
writeFile(t, ws, "src.md", "body")
tool.vaultStore.(*fakeVaultStore).outLinkErr = os.ErrInvalid
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{"doc_id": src.ID})
if res.IsError {
t.Fatalf("store error must not fail read: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "body") {
t.Fatalf("body missing: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "## Links") {
t.Fatalf("footer must be absent on store error: %s", res.ForLLM)
}
}
// --- 14. max_bytes clamp to ceiling (1MB). ---
func TestVaultRead_MaxBytes_ClampCeiling(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
docID := uuid.New()
doc := &store.VaultDocument{
ID: docID.String(), TenantID: tenantID.String(),
Scope: "shared", Path: "c.md", Title: "C", DocType: "note",
}
tool, ws := newVaultReadTestTool(t, doc)
// 1.2MB body — should be truncated to 1MB hard ceiling regardless of arg.
body := bytes.Repeat([]byte("x"), 1_200_000)
writeBytes(t, ws, "c.md", body)
res := tool.Execute(makeCtx(tenantID, agentID), map[string]any{
"doc_id": docID.String(),
"max_bytes": float64(5_000_000), // above ceiling
})
if res.IsError {
t.Fatalf("unexpected error: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "truncated") {
t.Fatalf("expected truncation marker for ceiling clamp, got head: %s", res.ForLLM[:min(200, len(res.ForLLM))])
}
}
// --- Namespace-fallback fakes -------------------------------------------------
type fakeKGStoreRead struct {
store.KnowledgeGraphStore
byID map[string]*store.Entity
}
func (f *fakeKGStoreRead) GetEntity(ctx context.Context, agentID, userID, entityID string) (*store.Entity, error) {
if f.byID == nil {
return nil, nil
}
if e, ok := f.byID[entityID]; ok {
return e, nil
}
return nil, nil
}
type fakeEpisodicStoreRead struct {
store.EpisodicStore
byID map[string]*store.EpisodicSummary
}
func (f *fakeEpisodicStoreRead) Get(ctx context.Context, id string) (*store.EpisodicSummary, error) {
if f.byID == nil {
return nil, nil
}
if e, ok := f.byID[id]; ok {
return e, nil
}
return nil, nil
}
// --- 15. vault_read with KG id → namespace redirect (not "document not found"). ---
func TestVaultRead_KGIDReturnsRedirect(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
kgID := uuid.New()
tool, _ := newVaultReadTestTool(t) // no vault docs seeded
kg := &fakeKGStoreRead{byID: map[string]*store.Entity{
kgID.String(): {ID: kgID.String(), Name: "KG_03", EntityType: "document"},
}}
tool.SetKGStore(kg)
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": kgID.String()})
if !res.IsError {
t.Fatalf("expected error, got: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "document not found") {
t.Fatalf("should redirect, not say 'document not found': %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "knowledge_graph_search") {
t.Fatalf("redirect must reference knowledge_graph_search: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "entity_id") {
t.Fatalf("redirect must name 'entity_id' param so LLM can self-correct: %s", res.ForLLM)
}
}
// --- 16. vault_read with episodic id → namespace redirect. ---
func TestVaultRead_EpisodicIDReturnsRedirect(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
epID := uuid.New()
tool, _ := newVaultReadTestTool(t)
ep := &fakeEpisodicStoreRead{byID: map[string]*store.EpisodicSummary{
epID.String(): {ID: epID},
}}
tool.SetEpisodicStore(ep)
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": epID.String()})
if !res.IsError {
t.Fatalf("expected error, got: %s", res.ForLLM)
}
if strings.Contains(res.ForLLM, "document not found") {
t.Fatalf("should redirect, not say 'document not found': %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "memory_expand") {
t.Fatalf("redirect must reference memory_expand: %s", res.ForLLM)
}
if !strings.Contains(res.ForLLM, "episodic_id") {
t.Fatalf("redirect must name 'episodic_id' so LLM can self-correct: %s", res.ForLLM)
}
}
// --- 17. vault_read with ID not in any store → preserves "document not found". ---
func TestVaultRead_TrulyMissingReturnsNotFound(t *testing.T) {
tenantID := uuid.New()
agentID := uuid.New()
tool, _ := newVaultReadTestTool(t)
tool.SetKGStore(&fakeKGStoreRead{})
tool.SetEpisodicStore(&fakeEpisodicStoreRead{})
res := tool.Execute(makeCtx(tenantID, agentID),
map[string]any{"doc_id": uuid.New().String()})
if !res.IsError || !strings.Contains(res.ForLLM, "not found") {
t.Fatalf("expected document not found, got: %s", res.ForLLM)
}
}