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

247 lines
6.7 KiB
Go

//go:build integration
package integration
import (
"database/sql"
"errors"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
func newVaultStore(db *sql.DB) *pg.PGVaultStore {
pg.InitSqlx(db)
vs := pg.NewPGVaultStore(db)
vs.SetEmbeddingProvider(newMockEmbedProvider())
return vs
}
func makeVaultDoc(tenantID, agentID, path, title string) *store.VaultDocument {
return &store.VaultDocument{
TenantID: tenantID,
AgentID: &agentID,
Scope: "personal",
Path: path,
Title: title,
DocType: "note",
ContentHash: "abc123",
}
}
// makeSharedVaultDoc builds a shared (agent_id=NULL, scope='shared') vault document.
// Used to seed characterization tests that verify shared docs are visible to agents.
func makeSharedVaultDoc(tenantID, path, title string) *store.VaultDocument {
return &store.VaultDocument{
TenantID: tenantID,
AgentID: nil, // NULL — shared doc
TeamID: nil,
Scope: "shared",
Path: path,
Title: title,
DocType: "note",
ContentHash: "abc123",
}
}
// makeTeamVaultDoc builds a team-scoped vault document (agent_id=NULL, team_id set, scope='team').
func makeTeamVaultDoc(tenantID, teamID, path, title string) *store.VaultDocument {
return &store.VaultDocument{
TenantID: tenantID,
AgentID: nil, // NULL for team docs
TeamID: &teamID,
Scope: "team",
Path: path,
Title: title,
DocType: "note",
ContentHash: "abc123",
}
}
func TestStoreVault_UpsertAndGetDocument(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, "notes/intro.md", "Introduction")
if err := vs.UpsertDocument(ctx, doc); err != nil {
t.Fatalf("UpsertDocument: %v", err)
}
if doc.ID == "" {
t.Fatal("expected doc.ID to be set after upsert")
}
// GetDocument by path.
got, err := vs.GetDocument(ctx, tid, aid, "notes/intro.md")
if err != nil {
t.Fatalf("GetDocument: %v", err)
}
if got.Title != "Introduction" {
t.Errorf("expected Title='Introduction', got %q", got.Title)
}
// GetDocumentByID.
byID, err := vs.GetDocumentByID(ctx, tid, doc.ID)
if err != nil {
t.Fatalf("GetDocumentByID: %v", err)
}
if byID.Path != "notes/intro.md" {
t.Errorf("expected Path='notes/intro.md', got %q", byID.Path)
}
// Re-upsert (same path) — should update title.
doc.Title = "Introduction Updated"
if err := vs.UpsertDocument(ctx, doc); err != nil {
t.Fatalf("UpsertDocument update: %v", err)
}
got2, err := vs.GetDocument(ctx, tid, aid, "notes/intro.md")
if err != nil {
t.Fatalf("GetDocument after update: %v", err)
}
if got2.Title != "Introduction Updated" {
t.Errorf("expected Title='Introduction Updated', got %q", got2.Title)
}
}
func TestStoreVault_DeleteDocument(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, "del/target.md", "To Delete")
if err := vs.UpsertDocument(ctx, doc); err != nil {
t.Fatalf("UpsertDocument: %v", err)
}
if err := vs.DeleteDocument(ctx, tid, aid, "del/target.md"); err != nil {
t.Fatalf("DeleteDocument: %v", err)
}
got, err := vs.GetDocument(ctx, tid, aid, "del/target.md")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("expected sql.ErrNoRows after delete, got err=%v doc=%v", err, got)
}
}
func TestStoreVault_ListDocuments(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, path := range []string{"list/a.md", "list/b.md", "list/c.md"} {
_ = i
doc := makeVaultDoc(tid, aid, path, "Doc "+path)
if err := vs.UpsertDocument(ctx, doc); err != nil {
t.Fatalf("UpsertDocument %s: %v", path, err)
}
}
docs, err := vs.ListDocuments(ctx, tid, aid, store.VaultListOptions{Limit: 20})
if err != nil {
t.Fatalf("ListDocuments: %v", err)
}
if len(docs) < 3 {
t.Errorf("expected at least 3 docs, got %d", len(docs))
}
}
func TestStoreVault_LinkManagement(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, "links/a.md", "Doc A")
docB := makeVaultDoc(tid, aid, "links/b.md", "Doc B")
if err := vs.UpsertDocument(ctx, docA); err != nil {
t.Fatalf("UpsertDocument A: %v", err)
}
if err := vs.UpsertDocument(ctx, docB); err != nil {
t.Fatalf("UpsertDocument B: %v", err)
}
link := &store.VaultLink{
FromDocID: docA.ID,
ToDocID: docB.ID,
LinkType: "wikilink",
Context: "mentioned in A",
}
if err := vs.CreateLink(ctx, link); err != nil {
t.Fatalf("CreateLink: %v", err)
}
// GetOutLinks from A — expect 1.
outLinks, err := vs.GetOutLinks(ctx, tid, docA.ID)
if err != nil {
t.Fatalf("GetOutLinks: %v", err)
}
if len(outLinks) != 1 {
t.Errorf("expected 1 outlink from A, got %d", len(outLinks))
}
if outLinks[0].ToDocID != docB.ID {
t.Errorf("outlink ToDocID: expected %s, got %s", docB.ID, outLinks[0].ToDocID)
}
// GetBacklinks for B — expect 1.
backlinks, err := vs.GetBacklinks(ctx, tid, docB.ID)
if err != nil {
t.Fatalf("GetBacklinks: %v", err)
}
if len(backlinks) != 1 {
t.Errorf("expected 1 backlink to B, got %d", len(backlinks))
}
// DeleteDocLinks for A — clears all outgoing links from A.
if err := vs.DeleteDocLinks(ctx, tid, docA.ID); err != nil {
t.Fatalf("DeleteDocLinks: %v", err)
}
outLinks2, err := vs.GetOutLinks(ctx, tid, docA.ID)
if err != nil {
t.Fatalf("GetOutLinks after delete: %v", err)
}
if len(outLinks2) != 0 {
t.Errorf("expected 0 outlinks after DeleteDocLinks, got %d", len(outLinks2))
}
}
func TestStoreVault_TenantIsolation(t *testing.T) {
db := testDB(t)
// seedTwoTenants returns (tenantA, tenantB, agentA, agentB).
tenantA, tenantB, agentA, agentB := seedTwoTenants(t, db)
ctx := tenantCtx(tenantA) // context for tenant A writes
vs := newVaultStore(db)
tidA := tenantA.String()
tidB := tenantB.String()
aidA := agentA.String()
aidB := agentB.String()
doc := makeVaultDoc(tidA, aidA, "iso/secret.md", "Tenant A Secret")
if err := vs.UpsertDocument(ctx, doc); err != nil {
t.Fatalf("UpsertDocument tenantA: %v", err)
}
// Tenant B querying its own agent should not find tenant A's document.
got, err := vs.GetDocument(ctx, tidB, aidB, "iso/secret.md")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("tenant B should not access tenant A doc: err=%v doc=%v", err, got)
}
}