Files
goclaw/tests/integration/v3_vault_visibility_matrix_test.go
viettranx 7cfcbbf9db fix(vault): include shared docs in agent read paths (#917)
- 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).
2026-04-16 14:17:48 +07:00

267 lines
8.1 KiB
Go

//go:build integration
package integration
import (
"context"
"database/sql"
"sort"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// seedAgentInTenant inserts an agent into an existing tenant for same-tenant multi-agent tests.
func seedAgentInTenant(t *testing.T, db *sql.DB, tenantID uuid.UUID) uuid.UUID {
t.Helper()
agentID := uuid.New()
key := "ta-" + agentID.String()[:8]
_, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES ($1, $2, $3, 'predefined', 'active', 'test', 'test-model', 'test-owner')
ON CONFLICT DO NOTHING`,
agentID, tenantID, key)
if err != nil {
t.Fatalf("seedAgentInTenant: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
})
return agentID
}
// vaultListPaths calls ListDocuments and returns sorted paths.
func vaultListPaths(t *testing.T, vs interface {
ListDocuments(ctx context.Context, tenantID, agentID string, opts store.VaultListOptions) ([]store.VaultDocument, error)
}, ctx context.Context, tenantID, agentID string) []string {
t.Helper()
docs, err := vs.ListDocuments(ctx, tenantID, agentID, store.VaultListOptions{Limit: 100})
if err != nil {
t.Fatalf("ListDocuments(%s): %v", agentID, err)
}
paths := make([]string, 0, len(docs))
for _, d := range docs {
paths = append(paths, d.Path)
}
sort.Strings(paths)
return paths
}
// vaultSearchPaths calls Search with optional teamID and returns sorted paths.
func vaultSearchPaths(t *testing.T, vs interface {
Search(ctx context.Context, opts store.VaultSearchOptions) ([]store.VaultSearchResult, error)
}, ctx context.Context, tenantID, agentID string, teamID *string) []string {
t.Helper()
results, err := vs.Search(ctx, store.VaultSearchOptions{
TenantID: tenantID,
AgentID: agentID,
TeamID: teamID,
MaxResults: 100,
})
if err != nil {
t.Fatalf("Search(%s): %v", agentID, err)
}
paths := make([]string, 0, len(results))
for _, r := range results {
paths = append(paths, r.Document.Path)
}
sort.Strings(paths)
return paths
}
// vaultTreeRootPaths calls ListTreeEntries at root and returns sorted entry paths.
func vaultTreeRootPaths(t *testing.T, vs interface {
ListTreeEntries(ctx context.Context, tenantID string, opts store.VaultTreeOptions) ([]store.VaultTreeEntry, error)
}, ctx context.Context, tenantID string, opts store.VaultTreeOptions) []string {
t.Helper()
entries, err := vs.ListTreeEntries(ctx, tenantID, opts)
if err != nil {
t.Fatalf("ListTreeEntries: %v", err)
}
paths := make([]string, 0, len(entries))
for _, e := range entries {
paths = append(paths, e.Path)
}
sort.Strings(paths)
return paths
}
// containsPath reports whether target appears in the sorted paths slice.
func containsPath(paths []string, target string) bool {
for _, p := range paths {
if p == target {
return true
}
}
return false
}
// TestStoreVault_VisibilityMatrix covers 9 matrix rows:
// personal/shared/team visibility by owner, other-agent, cross-tenant, team-member, non-member.
func TestStoreVault_VisibilityMatrix(t *testing.T) {
db := testDB(t)
vs := newVaultStore(db)
// Tenant A: agentA (owner), agentBinA (same-tenant non-team), teamMember.
tenantA, agentA := seedTenantAgent(t, db)
agentBinA := seedAgentInTenant(t, db, tenantA)
teamID, teamMember := seedTeam(t, db, tenantA, agentA)
// Tenant X: cross-tenant viewer.
tenantX, agentX := seedTenantAgent(t, db)
tidA := tenantA.String()
tidX := tenantX.String()
aidA := agentA.String()
aidB := agentBinA.String()
aidMember := teamMember.String()
aidX := agentX.String()
teamIDStr := teamID.String()
ctxA := tenantCtx(tenantA)
ctxX := tenantCtx(tenantX)
// Seed docs in tenantA.
const (
pathPersonal = "matrix/personal.md"
pathShared = "matrix/shared.md"
pathTeam = "matrix/team.md"
)
personal := makeVaultDoc(tidA, aidA, pathPersonal, "Personal A")
personal.Summary = "personal owner doc"
if err := vs.UpsertDocument(ctxA, personal); err != nil {
t.Fatalf("upsert personal: %v", err)
}
shared := makeSharedVaultDoc(tidA, pathShared, "Shared Doc")
shared.Summary = "shared tenant doc"
if err := vs.UpsertDocument(ctxA, shared); err != nil {
t.Fatalf("upsert shared: %v", err)
}
team := makeTeamVaultDoc(tidA, teamIDStr, pathTeam, "Team Doc")
team.Summary = "team scoped doc"
if err := vs.UpsertDocument(ctxA, team); err != nil {
t.Fatalf("upsert team: %v", err)
}
type matrixCase struct {
name string
getPaths func() []string
wantAll []string // all must appear
wantNone []string // none must appear
}
cases := []matrixCase{
{
// Row 1: personal doc visible to its owner via ListDocuments.
name: "personal_visible_to_owner",
getPaths: func() []string {
return vaultListPaths(t, vs, ctxA, tidA, aidA)
},
wantAll: []string{pathPersonal},
},
{
// Row 2: personal doc NOT visible to another agent in same tenant.
name: "personal_invisible_to_other_agent_same_tenant",
getPaths: func() []string {
return vaultListPaths(t, vs, ctxA, tidA, aidB)
},
wantNone: []string{pathPersonal},
},
{
// Row 3: shared doc visible to any agent in tenant via ListDocuments.
name: "shared_visible_to_any_agent_via_list",
getPaths: func() []string {
return vaultListPaths(t, vs, ctxA, tidA, aidA)
},
wantAll: []string{pathShared},
},
{
// Row 4: shared doc visible via ListTreeEntries(agentID=A).
name: "shared_visible_via_list_tree_agentA",
getPaths: func() []string {
// ListTreeEntries returns top-level entries; "matrix" folder should appear.
paths := vaultTreeRootPaths(t, vs, ctxA, tidA, store.VaultTreeOptions{AgentID: aidA, Path: ""})
// Drill into "matrix" to check shared.md is there.
children := vaultTreeRootPaths(t, vs, ctxA, tidA, store.VaultTreeOptions{AgentID: aidA, Path: "matrix"})
return append(paths, children...)
},
wantAll: []string{"matrix/shared.md"},
},
{
// Row 5: shared doc from tenantA NOT visible in cross-tenant search.
name: "shared_invisible_cross_tenant",
getPaths: func() []string {
return vaultSearchPaths(t, vs, ctxX, tidX, aidX, nil)
},
wantNone: []string{pathShared, pathPersonal, pathTeam},
},
{
// Row 6: team doc visible to a member of that team (Search with TeamID).
name: "team_doc_visible_to_member",
getPaths: func() []string {
return vaultSearchPaths(t, vs, ctxA, tidA, aidMember, &teamIDStr)
},
wantAll: []string{pathTeam},
},
{
// Row 7: team doc NOT visible in personal-only search (TeamID="", means team_id IS NULL).
// The store layer filters by team_id when a TeamID is provided; passing an empty string
// means "personal only" (team_id IS NULL). Membership enforcement is a handler concern.
name: "team_doc_invisible_in_personal_only_search",
getPaths: func() []string {
personalOnly := ""
return vaultSearchPaths(t, vs, ctxA, tidA, aidB, &personalOnly)
},
wantNone: []string{pathTeam},
},
{
// Row 8: shared doc visible to team member when RunContext.TeamID=T.
name: "shared_visible_to_team_member_with_teamID",
getPaths: func() []string {
return vaultSearchPaths(t, vs, ctxA, tidA, aidMember, &teamIDStr)
},
wantAll: []string{pathShared},
},
{
// Row 9: shared doc visible via ListTreeEntries with TeamID=T.
name: "shared_visible_via_list_tree_with_teamID",
getPaths: func() []string {
paths := vaultTreeRootPaths(t, vs, ctxA, tidA, store.VaultTreeOptions{
AgentID: aidMember,
TeamID: &teamIDStr,
Path: "",
})
children := vaultTreeRootPaths(t, vs, ctxA, tidA, store.VaultTreeOptions{
AgentID: aidMember,
TeamID: &teamIDStr,
Path: "matrix",
})
return append(paths, children...)
},
wantAll: []string{"matrix/shared.md"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.getPaths()
t.Logf("paths: %v", got)
for _, want := range tc.wantAll {
if !containsPath(got, want) {
t.Errorf("want %q in results %v", want, got)
}
}
for _, forbid := range tc.wantNone {
if containsPath(got, forbid) {
t.Errorf("path %q must NOT appear in results %v", forbid, got)
}
}
})
}
}