Files
goclaw/tests/integration/sqlite_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

339 lines
11 KiB
Go

//go:build sqlite || sqliteonly
package integration
import (
"context"
"sort"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// seedSQLiteAgent inserts an agent into an existing tenant in the SQLite test DB.
func seedSQLiteAgent(t *testing.T, stores *store.Stores, tenantID string) string {
t.Helper()
agentID := uuid.Must(uuid.NewV7()).String()
key := "sqla-" + agentID[:8]
stores.DB.Exec(
`INSERT INTO agents (id, agent_key, display_name, status, tenant_id, owner_id, model, provider)
VALUES (?, ?, 'B', 'active', ?, 'owner', 'gpt-4o', 'openai')`,
agentID, key, tenantID)
return agentID
}
// seedSQLiteTeam creates a team with two members in the SQLite test DB.
// Returns teamID and the member agent's ID (ownerID is agentA).
func seedSQLiteTeam(t *testing.T, stores *store.Stores, tenantID, ownerAgentID string) (teamID, memberAgentID string) {
t.Helper()
teamID = uuid.Must(uuid.NewV7()).String()
memberAgentID = uuid.Must(uuid.NewV7()).String()
memberKey := "sqlm-" + memberAgentID[:8]
stores.DB.Exec(
`INSERT INTO agents (id, agent_key, display_name, status, tenant_id, owner_id, model, provider)
VALUES (?, ?, 'M', 'active', ?, 'owner', 'gpt-4o', 'openai')`,
memberAgentID, memberKey, tenantID)
stores.DB.Exec(
`INSERT INTO agent_teams (id, tenant_id, name, lead_agent_id, status, settings, created_by)
VALUES (?, ?, ?, ?, 'active', '{"version":2}', 'test')`,
teamID, tenantID, "test-team-"+teamID[:8], ownerAgentID)
for _, m := range []struct{ id, role string }{{ownerAgentID, "lead"}, {memberAgentID, "member"}} {
stores.DB.Exec(
`INSERT INTO agent_team_members (team_id, agent_id, tenant_id, role) VALUES (?, ?, ?, ?)`,
teamID, m.id, tenantID, m.role)
}
return teamID, memberAgentID
}
// sqliteListPaths returns sorted paths from Vault.ListDocuments.
func sqliteListPaths(t *testing.T, stores *store.Stores, ctx context.Context, tenantID, agentID string) []string {
t.Helper()
docs, err := stores.Vault.ListDocuments(ctx, tenantID, agentID, store.VaultListOptions{Limit: 100})
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)
return paths
}
// sqliteSearchPaths returns sorted paths from Vault.Search.
// query must be non-empty; SQLite Search returns nil on empty query.
func sqliteSearchPaths(t *testing.T, stores *store.Stores, ctx context.Context, tenantID, agentID, query string, teamID *string) []string {
t.Helper()
if query == "" {
t.Fatal("sqliteSearchPaths: query must be non-empty for SQLite LIKE search")
}
results, err := stores.Vault.Search(ctx, store.VaultSearchOptions{
Query: query,
TenantID: tenantID,
AgentID: agentID,
TeamID: teamID,
MaxResults: 100,
})
if err != nil {
t.Fatalf("Search(%q): %v", query, err)
}
paths := make([]string, 0, len(results))
for _, r := range results {
paths = append(paths, r.Document.Path)
}
sort.Strings(paths)
return paths
}
// sqliteTreePaths returns sorted paths from Vault.ListTreeEntries at the given root.
func sqliteTreePaths(t *testing.T, stores *store.Stores, ctx context.Context, tenantID string, opts store.VaultTreeOptions) []string {
t.Helper()
entries, err := stores.Vault.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
}
// sqliteContainsPath reports whether target is in paths.
func sqliteContainsPath(paths []string, target string) bool {
for _, p := range paths {
if p == target {
return true
}
}
return false
}
// TestSQLiteVaultStore_VisibilityMatrix mirrors a subset of the PG matrix for SQLite.
// SQLite uses LIKE-based search (no FTS), so Search results may be narrower.
func TestSQLiteVaultStore_VisibilityMatrix(t *testing.T) {
stores := newSQLiteTestStores(t)
tenantID := uuid.Must(uuid.NewV7()).String()
agentA := uuid.Must(uuid.NewV7()).String()
// Seed tenant + agentA.
stores.DB.Exec(`INSERT INTO tenants (id, name, slug, status) VALUES (?, 'TenantA', 'ta', 'active')`, tenantID)
stores.DB.Exec(
`INSERT INTO agents (id, agent_key, display_name, status, tenant_id, owner_id, model, provider)
VALUES (?, 'matrix-a', 'A', 'active', ?, 'owner', 'gpt-4o', 'openai')`,
agentA, tenantID)
agentB := seedSQLiteAgent(t, stores, tenantID)
teamID, teamMember := seedSQLiteTeam(t, stores, tenantID, agentA)
ctx := store.WithTenantID(context.Background(), uuid.MustParse(tenantID))
const (
pathPersonal = "sqmatrix/personal.md"
pathShared = "sqmatrix/shared.md"
pathTeam = "sqmatrix/team.md"
)
// Seed personal doc (agentA-owned).
if err := stores.Vault.UpsertDocument(ctx, &store.VaultDocument{
TenantID: tenantID, AgentID: &agentA, Scope: "personal",
Path: pathPersonal, Title: "Personal A", DocType: "note", ContentHash: "sq-pa1",
Summary: "personal sqlite doc",
}); err != nil {
t.Fatalf("upsert personal: %v", err)
}
// Seed shared doc (agent_id=NULL).
if err := stores.Vault.UpsertDocument(ctx, &store.VaultDocument{
TenantID: tenantID, AgentID: nil, Scope: "shared",
Path: pathShared, Title: "Shared Doc", DocType: "note", ContentHash: "sq-sh1",
Summary: "shared sqlite doc",
}); err != nil {
t.Fatalf("upsert shared: %v", err)
}
// Seed team doc (team_id set, agent_id=NULL).
if err := stores.Vault.UpsertDocument(ctx, &store.VaultDocument{
TenantID: tenantID, AgentID: nil, TeamID: &teamID, Scope: "team",
Path: pathTeam, Title: "Team Doc", DocType: "note", ContentHash: "sq-tm1",
Summary: "team sqlite doc",
}); err != nil {
t.Fatalf("upsert team: %v", err)
}
type matrixCase struct {
name string
getPaths func() []string
wantAll []string
wantNone []string
}
cases := []matrixCase{
{
name: "personal_visible_to_owner_via_list",
getPaths: func() []string {
return sqliteListPaths(t, stores, ctx, tenantID, agentA)
},
wantAll: []string{pathPersonal},
},
{
name: "personal_invisible_to_other_agent_same_tenant",
getPaths: func() []string {
return sqliteListPaths(t, stores, ctx, tenantID, agentB)
},
wantNone: []string{pathPersonal},
},
{
name: "shared_visible_to_owner_via_list",
getPaths: func() []string {
return sqliteListPaths(t, stores, ctx, tenantID, agentA)
},
wantAll: []string{pathShared},
},
{
name: "shared_visible_to_other_agent_via_list",
getPaths: func() []string {
return sqliteListPaths(t, stores, ctx, tenantID, agentB)
},
wantAll: []string{pathShared},
},
{
name: "shared_visible_via_list_tree_agentA",
getPaths: func() []string {
root := sqliteTreePaths(t, stores, ctx, tenantID, store.VaultTreeOptions{AgentID: agentA, Path: ""})
children := sqliteTreePaths(t, stores, ctx, tenantID, store.VaultTreeOptions{AgentID: agentA, Path: "sqmatrix"})
return append(root, children...)
},
wantAll: []string{"sqmatrix/shared.md"},
},
{
name: "team_doc_visible_to_member_via_search",
getPaths: func() []string {
return sqliteSearchPaths(t, stores, ctx, tenantID, teamMember, "sqmatrix", &teamID)
},
wantAll: []string{pathTeam},
},
{
// personal-only search (TeamID="") must not return team-scoped docs.
name: "team_doc_invisible_in_personal_only_search",
getPaths: func() []string {
personalOnly := ""
return sqliteSearchPaths(t, stores, ctx, tenantID, agentB, "sqmatrix", &personalOnly)
},
wantNone: []string{pathTeam},
},
{
name: "shared_visible_to_team_member_with_teamID_via_search",
getPaths: func() []string {
return sqliteSearchPaths(t, stores, ctx, tenantID, teamMember, "sqmatrix", &teamID)
},
wantAll: []string{pathShared},
},
{
name: "shared_visible_via_list_tree_with_teamID",
getPaths: func() []string {
root := sqliteTreePaths(t, stores, ctx, tenantID, store.VaultTreeOptions{AgentID: teamMember, TeamID: &teamID, Path: ""})
children := sqliteTreePaths(t, stores, ctx, tenantID, store.VaultTreeOptions{AgentID: teamMember, TeamID: &teamID, Path: "sqmatrix"})
return append(root, children...)
},
wantAll: []string{"sqmatrix/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 !sqliteContainsPath(got, want) {
t.Errorf("want %q in results %v", want, got)
}
}
for _, forbid := range tc.wantNone {
if sqliteContainsPath(got, forbid) {
t.Errorf("path %q must NOT appear in results %v", forbid, got)
}
}
})
}
}
// TestSQLiteVaultStore_ScopeCheckTrigger verifies the SQLite BEFORE INSERT trigger
// (trg_vault_docs_scope_consistency_ins) fires RAISE(ABORT) for invalid scope/ownership
// combinations and accepts valid ones.
func TestSQLiteVaultStore_ScopeCheckTrigger(t *testing.T) {
stores := newSQLiteTestStores(t)
tenantID := uuid.Must(uuid.NewV7()).String()
agentID := uuid.Must(uuid.NewV7()).String()
suffix := uuid.Must(uuid.NewV7()).String()[:8]
stores.DB.Exec(`INSERT INTO tenants (id, name, slug, status) VALUES (?, 'T', 'sc', 'active')`, tenantID)
stores.DB.Exec(
`INSERT INTO agents (id, agent_key, display_name, status, tenant_id, owner_id, model, provider)
VALUES (?, 'sc-agt', 'A', 'active', ?, 'owner', 'gpt-4o', 'openai')`,
agentID, tenantID)
cases := []struct {
name string
query string
args []any
wantErr bool
}{
{
name: "reject_personal_null_agent_id",
query: `INSERT INTO vault_documents (id, tenant_id, scope, path, title, doc_type, content_hash)
VALUES (?, ?, 'personal', ?, 'bad', 'note', 'h1')`,
args: []any{uuid.Must(uuid.NewV7()).String(), tenantID, "sc/bad-personal-" + suffix},
wantErr: true,
},
{
name: "reject_team_null_team_id",
query: `INSERT INTO vault_documents (id, tenant_id, scope, path, title, doc_type, content_hash)
VALUES (?, ?, 'team', ?, 'bad', 'note', 'h2')`,
args: []any{uuid.Must(uuid.NewV7()).String(), tenantID, "sc/bad-team-" + suffix},
wantErr: true,
},
{
name: "reject_shared_with_agent_id",
query: `INSERT INTO vault_documents (id, tenant_id, agent_id, scope, path, title, doc_type, content_hash)
VALUES (?, ?, ?, 'shared', ?, 'bad', 'note', 'h3')`,
args: []any{uuid.Must(uuid.NewV7()).String(), tenantID, agentID, "sc/bad-shared-" + suffix},
wantErr: true,
},
{
name: "accept_custom_scope_null_agent_id",
query: `INSERT INTO vault_documents (id, tenant_id, scope, path, title, doc_type, content_hash)
VALUES (?, ?, 'custom', ?, 'ok', 'note', 'h4')`,
args: []any{uuid.Must(uuid.NewV7()).String(), tenantID, "sc/ok-custom-" + suffix},
wantErr: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := stores.DB.Exec(tc.query, tc.args...)
if tc.wantErr {
if err == nil {
t.Error("expected scope_consistency trigger error, got nil")
return
}
if !strings.Contains(err.Error(), "scope_consistency") {
t.Errorf("expected error containing 'scope_consistency', got: %v", err)
}
} else {
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
})
}
}