Files
Justin Truong 6666585b7b test(vault): move integration fixtures under the tenant workspace root
The vault_read fix changes what the injected workspace means: it is the
global root wired once at boot, not a per-tenant root, since one tool
instance serves every tenant. These tests seeded a non-master tenant but
wrote fixtures straight to the injected root, so they only passed while
reads ignored the tenant layer.

Write fixtures to config.TenantWorkspace(base, tenantID, slug) and carry
the slug in context, matching a real run. tenantSlug is now the single
source of truth shared with seedTenantAgent, so on-disk layout in a test
cannot drift from the seed.
2026-08-03 23:10:13 +07:00

233 lines
8.9 KiB
Go

//go:build integration
package integration
import (
"context"
"database/sql"
"os"
"sync"
"testing"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
const defaultTestDSN = "postgres://postgres:test@localhost:5433/goclaw_test?sslmode=disable"
var (
sharedDB *sql.DB
sharedDBOnce sync.Once
sharedDBErr error
)
// testDB connects to the test PG instance, runs migrations once, and returns
// a shared *sql.DB. Skips test if PG is unreachable.
func testDB(t *testing.T) *sql.DB {
t.Helper()
sharedDBOnce.Do(func() {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = defaultTestDSN
}
db, err := sql.Open("pgx", dsn)
if err != nil {
sharedDBErr = err
return
}
if err := db.Ping(); err != nil {
sharedDBErr = err
return
}
// Run migrations once for the entire test run.
m, err := migrate.New("file://../../migrations", dsn)
if err != nil {
sharedDBErr = err
return
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
sharedDBErr = err
return
}
m.Close()
// Initialize pg package's sqlx wrapper. Without this, any store
// method that uses pkgSqlxDB.SelectContext panics on nil deref if
// the test runs before any other test happens to call InitSqlx.
// Centralizing here removes the ordering-dependency land mine.
pg.InitSqlx(db)
sharedDB = db
})
if sharedDBErr != nil {
t.Skipf("test PG not available: %v", sharedDBErr)
}
return sharedDB
}
// seedTenantAgent creates a minimal tenant + agent for FK satisfaction.
// Returns tenantID + agentID. Each test gets unique IDs for isolation.
func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
t.Helper()
tenantID = uuid.New()
agentID = uuid.New()
agentKey := "test-" + agentID.String()[:8]
// Insert tenant (minimal required fields).
_, err := db.Exec(
`INSERT INTO tenants (id, name, slug, status) VALUES ($1, $2, $3, 'active')
ON CONFLICT DO NOTHING`,
tenantID, "test-tenant-"+tenantID.String()[:8], tenantSlug(tenantID))
if err != nil {
t.Fatalf("seed tenant: %v", err)
}
// Insert agent (minimal required fields including owner_id).
_, 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, agentKey)
if err != nil {
t.Fatalf("seed agent: %v", err)
}
// Cleanup after test — delete in FK order (children first, parents last).
t.Cleanup(func() {
// Knowledge stores — must run BEFORE agent_teams is deleted. vault_documents.team_id
// is ON DELETE SET NULL with a BEFORE UPDATE trigger that force-sets scope='personal',
// which violates vault_documents_scope_consistency for team-scoped docs whose agent_id
// is NULL. Deleting the rows outright avoids the trigger entirely.
// vault_links has no tenant_id column (it links vault_documents by from/to_doc_id) and
// cascades automatically via vault_links_{from,to}_doc_id_fkey ON DELETE CASCADE, so no
// explicit delete is needed here.
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Team-related (deepest children first)
db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_attachments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_tasks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_team_members WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_teams WHERE tenant_id = $1", tenantID)
// Episodic
db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID)
// Cron
db.Exec("DELETE FROM cron_run_logs WHERE job_id IN (SELECT id FROM cron_jobs WHERE tenant_id = $1)", tenantID)
db.Exec("DELETE FROM cron_jobs WHERE tenant_id = $1", tenantID)
// Sessions
db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID)
// Skills
db.Exec("DELETE FROM skill_agent_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM skill_user_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM skills WHERE tenant_id = $1", tenantID)
// Security stores
db.Exec("DELETE FROM mcp_user_credentials WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_access_requests WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_user_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_agent_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_servers WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM secure_cli_user_credentials WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM secure_cli_agent_grants WHERE binary_id IN (SELECT id FROM secure_cli_binaries WHERE tenant_id = $1)", tenantID)
db.Exec("DELETE FROM secure_cli_binaries WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM api_keys WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_config_permissions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM channel_contacts WHERE tenant_id = $1", tenantID)
// Agent-related
db.Exec("DELETE FROM agent_shares WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID)
// Note: agent_user_profiles does not exist in the current schema (the table is
// user_agent_profiles, scoped by tenant_id + agent_id; there is nothing per-agent
// to clean here beyond what tenant-scoped deletes below already cover).
db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
db.Exec("DELETE FROM tenants WHERE id = $1", tenantID)
})
return tenantID, agentID
}
// tenantCtx returns a context with tenant ID set for store scoping.
func tenantCtx(tenantID uuid.UUID) context.Context {
return store.WithTenantID(context.Background(), tenantID)
}
// tenantSlug returns the slug seedTenantAgent assigns to a seeded tenant.
// Single source of truth so on-disk layout in a test cannot drift from the seed.
func tenantSlug(tenantID uuid.UUID) string {
return "t" + tenantID.String()[:8]
}
// tenantCtxSlug is tenantCtx plus the tenant slug. Real runs carry the slug in
// context, and tools that resolve a tenant-scoped workspace need it: without a
// slug config.TenantScopedDir deliberately falls back to an id-named directory
// (see its doc comment — an empty slug must never resolve to the tenants/ parent).
func tenantCtxSlug(tenantID uuid.UUID) context.Context {
return store.WithTenantSlug(tenantCtx(tenantID), tenantSlug(tenantID))
}
// tenantWorkspaceDir creates and returns the tenant-scoped workspace root under
// base — what config.TenantWorkspace resolves to for a seeded (non-master) tenant.
// Vault document paths are stored relative to THIS directory, not to base: base is
// the global workspace root wired once at boot, and one tool instance serves every
// tenant. Fixtures written straight to base are only reachable by the master tenant.
func tenantWorkspaceDir(t *testing.T, base string, tenantID uuid.UUID) string {
t.Helper()
dir := config.TenantWorkspace(base, tenantID, tenantSlug(tenantID))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir tenant workspace: %v", err)
}
return dir
}
// userCtx returns a context with both tenant ID and user ID set.
func userCtx(tenantID uuid.UUID, userID string) context.Context {
ctx := store.WithTenantID(context.Background(), tenantID)
return store.WithUserID(ctx, userID)
}
// crossTenantCtx returns a context that bypasses tenant scoping.
func crossTenantCtx() context.Context {
return store.WithCrossTenant(
store.WithTenantID(context.Background(), store.MasterTenantID),
)
}
func allowLoopbackForTest(t *testing.T) {
t.Helper()
security.SetAllowLoopbackForTest(true)
t.Cleanup(func() {
security.SetAllowLoopbackForTest(false)
})
}
// testEncryptionKey is a fixed 32-byte key for stores that require AES-256-GCM encryption in tests.
const testEncryptionKey = "0123456789abcdef0123456789abcdef"