mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +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).
247 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|