mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +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).
267 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|