mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 12:14:40 +00:00
- Patch vault_search, ListDocuments, CountDocuments, ListTreeEntries to include shared docs (agent_id IS NULL) for agents in the tenant - Keep DELETE/GetDocument/GetByBasename strict (auth-intent) - Add CHECK invariant vault_documents_scope_consistency (PG migration 000055 NOT VALID, SQLite triggers v24) to prevent future drift - Update docs and changelog Affects PostgreSQL and SQLite (desktop edition).
722 lines
21 KiB
Go
722 lines
21 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// TestStoreVault_CountDocuments verifies CountDocuments returns correct count per tenant+agent.
|
|
func TestStoreVault_CountDocuments(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
for _, p := range []string{"count/a.md", "count/b.md", "count/c.md"} {
|
|
if err := vs.UpsertDocument(ctx, makeVaultDoc(tid, aid, p, "Count Doc "+p)); err != nil {
|
|
t.Fatalf("UpsertDocument %s: %v", p, err)
|
|
}
|
|
}
|
|
|
|
n, err := vs.CountDocuments(ctx, tid, aid, store.VaultListOptions{})
|
|
if err != nil {
|
|
t.Fatalf("CountDocuments: %v", err)
|
|
}
|
|
if n < 3 {
|
|
t.Errorf("CountDocuments = %d, want >= 3", n)
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_GetDocumentsByIDs verifies batch fetch with tenant isolation.
|
|
func TestStoreVault_GetDocumentsByIDs(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
docA := makeVaultDoc(tid, aid, "batch/a.md", "Batch A")
|
|
docB := makeVaultDoc(tid, aid, "batch/b.md", "Batch B")
|
|
for _, d := range []*store.VaultDocument{docA, docB} {
|
|
if err := vs.UpsertDocument(ctx, d); err != nil {
|
|
t.Fatalf("UpsertDocument: %v", err)
|
|
}
|
|
}
|
|
|
|
results, err := vs.GetDocumentsByIDs(ctx, tid, []string{docA.ID, docB.ID})
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentsByIDs: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("GetDocumentsByIDs len = %d, want 2", len(results))
|
|
}
|
|
|
|
byID := map[string]store.VaultDocument{}
|
|
for _, d := range results {
|
|
byID[d.ID] = d
|
|
}
|
|
if byID[docA.ID].Title != "Batch A" {
|
|
t.Errorf("docA title = %q, want %q", byID[docA.ID].Title, "Batch A")
|
|
}
|
|
if byID[docB.ID].Title != "Batch B" {
|
|
t.Errorf("docB title = %q, want %q", byID[docB.ID].Title, "Batch B")
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_GetDocumentsByIDs_CrossTenantIsolation verifies cross-tenant isolation in batch fetch.
|
|
func TestStoreVault_GetDocumentsByIDs_CrossTenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, agentA := seedTenantAgent(t, db)
|
|
tenantB, _ := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
vs := newVaultStore(db)
|
|
|
|
tidA := tenantA.String()
|
|
tidB := tenantB.String()
|
|
aidA := agentA.String()
|
|
|
|
docA := makeVaultDoc(tidA, aidA, "iso/secret.md", "Tenant A Secret")
|
|
if err := vs.UpsertDocument(ctxA, docA); err != nil {
|
|
t.Fatalf("UpsertDocument tenantA: %v", err)
|
|
}
|
|
|
|
// Fetch with tenant B — should return empty slice (ID exists but wrong tenant).
|
|
results, err := vs.GetDocumentsByIDs(ctxA, tidB, []string{docA.ID})
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentsByIDs cross-tenant: %v", err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 results for cross-tenant fetch, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_UpdateHash verifies UpdateHash modifies the content_hash column.
|
|
func TestStoreVault_UpdateHash(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
doc := makeVaultDoc(tid, aid, "hash/doc.md", "Hash Update Test")
|
|
if err := vs.UpsertDocument(ctx, doc); err != nil {
|
|
t.Fatalf("UpsertDocument: %v", err)
|
|
}
|
|
|
|
newHash := "deadbeef" + uuid.New().String()[:8]
|
|
if err := vs.UpdateHash(ctx, tid, doc.ID, newHash); err != nil {
|
|
t.Fatalf("UpdateHash: %v", err)
|
|
}
|
|
|
|
got, err := vs.GetDocumentByID(ctx, tid, doc.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDocumentByID after UpdateHash: %v", err)
|
|
}
|
|
if got.ContentHash != newHash {
|
|
t.Errorf("ContentHash = %q, want %q", got.ContentHash, newHash)
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_ListDocuments_DocTypeFilter verifies DocTypes filter on ListDocuments.
|
|
func TestStoreVault_ListDocuments_DocTypeFilter(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
// Insert docs of two different types.
|
|
for _, tc := range []struct {
|
|
path string
|
|
docType string
|
|
}{
|
|
{"types/note1.md", "note"},
|
|
{"types/note2.md", "note"},
|
|
{"types/skill1.md", "skill"},
|
|
} {
|
|
d := makeVaultDoc(tid, aid, tc.path, "Doc "+tc.path)
|
|
d.DocType = tc.docType
|
|
if err := vs.UpsertDocument(ctx, d); err != nil {
|
|
t.Fatalf("UpsertDocument %s: %v", tc.path, err)
|
|
}
|
|
}
|
|
|
|
notes, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{
|
|
DocTypes: []string{"note"},
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments notes filter: %v", err)
|
|
}
|
|
for _, d := range notes {
|
|
if d.DocType != "note" {
|
|
t.Errorf("ListDocuments with DocType=note returned doc with type=%q", d.DocType)
|
|
}
|
|
}
|
|
|
|
skills, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{
|
|
DocTypes: []string{"skill"},
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments skills filter: %v", err)
|
|
}
|
|
for _, d := range skills {
|
|
if d.DocType != "skill" {
|
|
t.Errorf("ListDocuments with DocType=skill returned doc with type=%q", d.DocType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_FTSSearch verifies text search returns relevant results.
|
|
func TestStoreVault_FTSSearch(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
docs := []struct {
|
|
path string
|
|
title string
|
|
summary string
|
|
}{
|
|
{"fts/go.md", "Go Programming", "Go concurrency goroutines channels"},
|
|
{"fts/python.md", "Python Basics", "Python scripting data science"},
|
|
{"fts/rust.md", "Rust Systems", "Rust memory safety ownership model"},
|
|
}
|
|
for _, d := range docs {
|
|
vd := makeVaultDoc(tid, aid, d.path, d.title)
|
|
vd.Summary = d.summary
|
|
if err := vs.UpsertDocument(ctx, vd); err != nil {
|
|
t.Fatalf("UpsertDocument %s: %v", d.path, err)
|
|
}
|
|
}
|
|
|
|
// Search for "goroutines" — should match the Go doc.
|
|
results, err := vs.Search(ctx, store.VaultSearchOptions{
|
|
Query: "goroutines",
|
|
TenantID: tid,
|
|
AgentID: aid,
|
|
MaxResults: 5,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
// The Go doc must appear in results (FTS hit on summary).
|
|
found := false
|
|
for _, r := range results {
|
|
if r.Document.Path == "fts/go.md" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found && len(results) > 0 {
|
|
// If search returned something, verify it's relevant (not strict — FTS ranking varies).
|
|
t.Logf("Search returned %d results but fts/go.md not top-ranked — acceptable", len(results))
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_DeleteDocument_ErrNoRows verifies correct error after delete.
|
|
func TestStoreVault_GetDocument_NotFound_ErrNoRows(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
_, err := vs.GetDocument(ctx, tid, aid, "nonexistent/path.md")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Errorf("expected sql.ErrNoRows for missing doc, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Link_TenantIsolation verifies vault links respect tenant boundary.
|
|
func TestStoreVault_Link_TenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, agentA := seedTenantAgent(t, db)
|
|
tenantB, agentB := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
vs := newVaultStore(db)
|
|
|
|
tidA := tenantA.String()
|
|
tidB := tenantB.String()
|
|
aidA := agentA.String()
|
|
aidB := agentB.String()
|
|
|
|
// Create docs in tenant A.
|
|
docA1 := makeVaultDoc(tidA, aidA, "link-iso/a1.md", "A Doc 1")
|
|
docA2 := makeVaultDoc(tidA, aidA, "link-iso/a2.md", "A Doc 2")
|
|
for _, d := range []*store.VaultDocument{docA1, docA2} {
|
|
if err := vs.UpsertDocument(ctxA, d); err != nil {
|
|
t.Fatalf("UpsertDocument tenantA: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create a link between docs in tenant A.
|
|
link := &store.VaultLink{
|
|
FromDocID: docA1.ID,
|
|
ToDocID: docA2.ID,
|
|
LinkType: "wikilink",
|
|
Context: "cross-link test",
|
|
}
|
|
if err := vs.CreateLink(ctxA, link); err != nil {
|
|
t.Fatalf("CreateLink tenantA: %v", err)
|
|
}
|
|
|
|
// Tenant B must not see tenant A's outlinks.
|
|
outB, err := vs.GetOutLinks(ctxA, tidB, docA1.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetOutLinks cross-tenant: %v", err)
|
|
}
|
|
for _, l := range outB {
|
|
if l.FromDocID == docA1.ID {
|
|
t.Error("tenant B sees tenant A vault link — isolation broken")
|
|
}
|
|
}
|
|
|
|
// Tenant A must see its own link.
|
|
outA, err := vs.GetOutLinks(ctxA, tidA, docA1.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetOutLinks tenantA: %v", err)
|
|
}
|
|
if len(outA) != 1 {
|
|
t.Errorf("tenant A outlinks = %d, want 1", len(outA))
|
|
}
|
|
|
|
// Create a doc in tenant B (separate cleanup via seedTenantAgent).
|
|
docB := makeVaultDoc(tidB, aidB, "link-iso/b.md", "B Doc")
|
|
_ = docB // suppress unused warning; seedTenantAgent cleanup handles vault_documents via tenantB
|
|
}
|
|
|
|
// TestStoreVault_ListDocuments_Pagination verifies limit/offset pagination.
|
|
func TestStoreVault_ListDocuments_Pagination(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
for i := 0; i < 5; i++ {
|
|
p := "page/doc" + string(rune('A'+i)) + ".md"
|
|
if err := vs.UpsertDocument(ctx, makeVaultDoc(tid, aid, p, "Page Doc "+p)); err != nil {
|
|
t.Fatalf("UpsertDocument %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
t.Run("page1_limit2", func(t *testing.T) {
|
|
docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 0})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments page1: %v", err)
|
|
}
|
|
if len(docs) != 2 {
|
|
t.Errorf("page1 len = %d, want 2", len(docs))
|
|
}
|
|
})
|
|
|
|
t.Run("page2_limit2", func(t *testing.T) {
|
|
docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 2})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments page2: %v", err)
|
|
}
|
|
if len(docs) == 0 {
|
|
t.Error("page2: expected at least 1 doc")
|
|
}
|
|
})
|
|
|
|
t.Run("last_page", func(t *testing.T) {
|
|
docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 2, Offset: 4})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments last page: %v", err)
|
|
}
|
|
// At least 1 (the 5th doc).
|
|
if len(docs) < 1 {
|
|
t.Error("last page: expected at least 1 doc")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---- Characterization tests: shared doc visibility (RED on unpatched code) ----
|
|
|
|
// TestStoreVault_List_IncludesShared asserts ListDocuments returns shared (agent_id=NULL) docs
|
|
// alongside personal docs for the same tenant+agent.
|
|
// BUG: currently fails — strict "AND agent_id = $N" excludes NULL rows.
|
|
func TestStoreVault_List_IncludesShared(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
// Seed personal doc (agent_id = A1).
|
|
personal := makeVaultDoc(tid, aid, "agents/a1/alpha.md", "personal alpha doc")
|
|
personal.Summary = "personal alpha text"
|
|
if err := vs.UpsertDocument(ctx, personal); err != nil {
|
|
t.Fatalf("upsert personal: %v", err)
|
|
}
|
|
|
|
// Seed shared doc (agent_id = NULL).
|
|
shared := makeSharedVaultDoc(tid, "shared/alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 50})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments: %v", err)
|
|
}
|
|
|
|
paths := make([]string, 0, len(docs))
|
|
for _, d := range docs {
|
|
paths = append(paths, d.Path)
|
|
}
|
|
sort.Strings(paths)
|
|
t.Logf("ListDocuments returned %d docs: %v", len(docs), paths)
|
|
|
|
foundShared := false
|
|
foundPersonal := false
|
|
for _, d := range docs {
|
|
if d.Path == "shared/alpha.md" {
|
|
foundShared = true
|
|
}
|
|
if d.Path == "agents/a1/alpha.md" {
|
|
foundPersonal = true
|
|
}
|
|
}
|
|
|
|
if !foundPersonal {
|
|
t.Error("ListDocuments: personal doc agents/a1/alpha.md not found")
|
|
}
|
|
if !foundShared {
|
|
t.Errorf("ListDocuments: shared doc shared/alpha.md not found — BUG: shared docs excluded (agent_id=NULL filtered out)")
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Count_IncludesShared asserts CountDocuments counts shared docs.
|
|
// BUG: currently fails — shared docs excluded from count.
|
|
func TestStoreVault_Count_IncludesShared(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
personal := makeVaultDoc(tid, aid, "agents/a1/cnt-alpha.md", "personal alpha doc")
|
|
personal.Summary = "personal alpha text"
|
|
if err := vs.UpsertDocument(ctx, personal); err != nil {
|
|
t.Fatalf("upsert personal: %v", err)
|
|
}
|
|
|
|
shared := makeSharedVaultDoc(tid, "shared/cnt-alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
n, err := vs.CountDocuments(ctx, tid, aid, store.VaultListOptions{})
|
|
if err != nil {
|
|
t.Fatalf("CountDocuments: %v", err)
|
|
}
|
|
t.Logf("CountDocuments = %d", n)
|
|
|
|
if n < 2 {
|
|
t.Errorf("CountDocuments = %d, want >= 2 — BUG: shared docs (agent_id=NULL) excluded from count", n)
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Search_SharedDocsVisible asserts Search (FTS) returns shared docs.
|
|
// BUG: currently fails — "AND agent_id = $N" in ftsSearch excludes NULL rows.
|
|
func TestStoreVault_Search_SharedDocsVisible(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
personal := makeVaultDoc(tid, aid, "agents/a1/srch-alpha.md", "personal alpha doc")
|
|
personal.Summary = "personal alpha text"
|
|
if err := vs.UpsertDocument(ctx, personal); err != nil {
|
|
t.Fatalf("upsert personal: %v", err)
|
|
}
|
|
|
|
shared := makeSharedVaultDoc(tid, "shared/srch-alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
results, err := vs.Search(ctx, store.VaultSearchOptions{
|
|
Query: "alpha",
|
|
TenantID: tid,
|
|
AgentID: aid,
|
|
MaxResults: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
paths := make([]string, 0, len(results))
|
|
for _, r := range results {
|
|
paths = append(paths, r.Document.Path)
|
|
}
|
|
sort.Strings(paths)
|
|
t.Logf("Search returned %d results: %v", len(results), paths)
|
|
|
|
foundShared := false
|
|
foundPersonal := false
|
|
for _, r := range results {
|
|
if r.Document.Path == "shared/srch-alpha.md" {
|
|
foundShared = true
|
|
}
|
|
if r.Document.Path == "agents/a1/srch-alpha.md" {
|
|
foundPersonal = true
|
|
}
|
|
}
|
|
|
|
if !foundPersonal {
|
|
t.Error("Search: personal doc agents/a1/srch-alpha.md not found")
|
|
}
|
|
if !foundShared {
|
|
t.Errorf("Search: shared doc shared/srch-alpha.md not found — BUG: shared docs (agent_id=NULL) excluded from FTS results")
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Tree_SharedDocsVisible asserts ListTreeEntries includes shared docs.
|
|
// BUG: currently fails — appendTreeFilters adds "AND agent_id = $N" (PG line 774).
|
|
func TestStoreVault_Tree_SharedDocsVisible(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
personal := makeVaultDoc(tid, aid, "agents/a1/tree-alpha.md", "personal alpha doc")
|
|
personal.Summary = "personal alpha text"
|
|
if err := vs.UpsertDocument(ctx, personal); err != nil {
|
|
t.Fatalf("upsert personal: %v", err)
|
|
}
|
|
|
|
shared := makeSharedVaultDoc(tid, "shared/tree-alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
entries, err := vs.ListTreeEntries(ctx, tid, store.VaultTreeOptions{AgentID: aid, Path: ""})
|
|
if err != nil {
|
|
t.Fatalf("ListTreeEntries: %v", err)
|
|
}
|
|
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
names = append(names, e.Path)
|
|
}
|
|
sort.Strings(names)
|
|
t.Logf("ListTreeEntries returned %d entries: %v", len(entries), names)
|
|
|
|
foundSharedFolder := false
|
|
for _, e := range entries {
|
|
// The shared doc is at shared/tree-alpha.md; at root level we expect "shared" folder entry.
|
|
if e.Path == "shared" || e.Path == "shared/tree-alpha.md" {
|
|
foundSharedFolder = true
|
|
}
|
|
}
|
|
|
|
if !foundSharedFolder {
|
|
t.Errorf("ListTreeEntries: 'shared' folder/file not found — BUG: shared docs (agent_id=NULL) excluded from tree (appendTreeFilters strict agent filter)")
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Search_TeamAgentSeesShared asserts that a team-context Search
|
|
// returns shared docs in addition to personal + team docs.
|
|
// BUG: currently fails — searchTeamFilter.append uses strict "AND team_id = $N"
|
|
// when teamID is set, which excludes shared docs (team_id=NULL, agent_id=NULL).
|
|
// NOTE: Search uses opts.TeamID directly (not RunContext).
|
|
func TestStoreVault_Search_TeamAgentSeesShared(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
teamID, _ := seedTeam(t, db, tenantID, agentID)
|
|
teamIDStr := teamID.String()
|
|
|
|
// Personal doc (agent_id = A1, team_id = NULL).
|
|
personal := makeVaultDoc(tid, aid, "agents/a1/team-alpha.md", "personal alpha doc")
|
|
personal.Summary = "personal alpha text"
|
|
if err := vs.UpsertDocument(ctx, personal); err != nil {
|
|
t.Fatalf("upsert personal: %v", err)
|
|
}
|
|
|
|
// Team doc (agent_id = NULL, team_id = T1).
|
|
team := makeTeamVaultDoc(tid, teamIDStr, "teams/t1/team-alpha.md", "team alpha doc")
|
|
team.Summary = "team alpha text"
|
|
if err := vs.UpsertDocument(ctx, team); err != nil {
|
|
t.Fatalf("upsert team: %v", err)
|
|
}
|
|
|
|
// Shared doc (agent_id = NULL, team_id = NULL).
|
|
shared := makeSharedVaultDoc(tid, "shared/team-alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
results, err := vs.Search(ctx, store.VaultSearchOptions{
|
|
Query: "alpha",
|
|
TenantID: tid,
|
|
AgentID: aid,
|
|
TeamID: &teamIDStr,
|
|
MaxResults: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search with TeamID: %v", err)
|
|
}
|
|
|
|
paths := make([]string, 0, len(results))
|
|
for _, r := range results {
|
|
paths = append(paths, r.Document.Path)
|
|
}
|
|
sort.Strings(paths)
|
|
t.Logf("Search (TeamID=%s) returned %d results: %v", teamIDStr, len(results), paths)
|
|
|
|
foundPersonal := false
|
|
foundTeam := false
|
|
foundShared := false
|
|
for _, r := range results {
|
|
switch r.Document.Path {
|
|
case "agents/a1/team-alpha.md":
|
|
foundPersonal = true
|
|
case "teams/t1/team-alpha.md":
|
|
foundTeam = true
|
|
case "shared/team-alpha.md":
|
|
foundShared = true
|
|
}
|
|
}
|
|
|
|
if !foundPersonal {
|
|
t.Error("Search: personal doc agents/a1/team-alpha.md not found")
|
|
}
|
|
if !foundTeam {
|
|
t.Error("Search: team doc teams/t1/team-alpha.md not found")
|
|
}
|
|
if !foundShared {
|
|
t.Errorf("Search: shared doc shared/team-alpha.md not found — BUG: strict team_id filter excludes shared docs (team_id=NULL, agent_id=NULL)")
|
|
}
|
|
}
|
|
|
|
// ---- Guard tests (must PASS now and stay green through all phases) ----
|
|
|
|
// TestStoreVault_Delete_RemainsStrict asserts that DeleteDocument with a specific agentID
|
|
// does NOT delete shared docs (agent_id=NULL) — the strict agent filter protects shared data.
|
|
func TestStoreVault_Delete_RemainsStrict(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantID, agentID := seedTenantAgent(t, db)
|
|
ctx := tenantCtx(tenantID)
|
|
vs := newVaultStore(db)
|
|
|
|
tid := tenantID.String()
|
|
aid := agentID.String()
|
|
|
|
shared := makeSharedVaultDoc(tid, "shared/del-guard.md", "shared alpha doc")
|
|
if err := vs.UpsertDocument(ctx, shared); err != nil {
|
|
t.Fatalf("upsert shared: %v", err)
|
|
}
|
|
|
|
// Attempt to delete the shared doc using agentID — should NOT match (agent_id=NULL ≠ agentID).
|
|
if err := vs.DeleteDocument(ctx, tid, aid, "shared/del-guard.md"); err != nil {
|
|
t.Fatalf("DeleteDocument: %v", err)
|
|
}
|
|
|
|
// Verify shared doc still exists via raw SQL (bypasses store agent filter).
|
|
var count int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM vault_documents WHERE tenant_id = $1 AND path = $2 AND agent_id IS NULL`,
|
|
tenantID, "shared/del-guard.md").Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("raw count query: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("shared doc deleted by agent-scoped DeleteDocument — guard broken (count=%d, want 1)", count)
|
|
}
|
|
}
|
|
|
|
// TestStoreVault_Search_CrossTenantIsolation asserts that shared docs from tenant A
|
|
// are NOT visible to a search from tenant B.
|
|
func TestStoreVault_Search_CrossTenantIsolation(t *testing.T) {
|
|
db := testDB(t)
|
|
tenantA, agentA := seedTenantAgent(t, db)
|
|
tenantB, agentB := seedTenantAgent(t, db)
|
|
ctxA := tenantCtx(tenantA)
|
|
vs := newVaultStore(db)
|
|
|
|
tidA := tenantA.String()
|
|
tidB := tenantB.String()
|
|
aidA := agentA.String()
|
|
aidB := agentB.String()
|
|
|
|
// Seed shared doc in tenant A.
|
|
shared := makeSharedVaultDoc(tidA, "shared/iso-alpha.md", "shared alpha doc")
|
|
shared.Summary = "shared alpha text"
|
|
if err := vs.UpsertDocument(ctxA, shared); err != nil {
|
|
t.Fatalf("upsert shared tenantA: %v", err)
|
|
}
|
|
|
|
// Search from tenant B — must return 0 results for "alpha".
|
|
results, err := vs.Search(context.Background(), store.VaultSearchOptions{
|
|
Query: "alpha",
|
|
TenantID: tidB,
|
|
AgentID: aidB,
|
|
MaxResults: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search tenantB: %v", err)
|
|
}
|
|
|
|
t.Logf("Cross-tenant search returned %d results (want 0)", len(results))
|
|
|
|
// Filter to only shared paths to catch leaks.
|
|
for _, r := range results {
|
|
if r.Document.TenantID == tidA {
|
|
t.Errorf("cross-tenant isolation broken: tenant B search returned tenant A doc %q", r.Document.Path)
|
|
}
|
|
}
|
|
|
|
// Confirm agent A doc is isolated as a sanity baseline.
|
|
_ = aidA
|
|
}
|