Files
f5917b0e46 fix(backup): harden tenant restore preview and lookup handling (#920)
* fix(backup): harden tenant restore preview and lookup handling

* test(pg): fix hook test migration path

* test(ci): stabilize race coverage tests

* fix(hooks): scope GetByID and allow loopback tests

* fix(backup): harden tenant restore replace/new contracts + race-safe SSRF flag

- Replace mode no longer deletes the tenants row (FK safe vs excluded
  diagnostic tables: traces, activity_logs, usage_snapshots, spans,
  embedding_cache, pairing_requests, paired_devices,
  channel_pending_messages, cron_run_logs). Metadata is preserved in place.
- shouldRestoreTable now excludes tenants for both new and replace modes.
- CLI: add validateTenantRestoreFlags guardrail. mode=new requires
  --new-tenant-slug and rejects --tenant/--tenant-id; upsert/replace warn
  on stray --new-tenant-slug; invalid --mode values rejected. TAB in help
  text fixed; flag descriptions clarified.
- HTTP: resolveRestoreTarget rejects tenant_id for mode=new regardless
  of tenant_slug (matches CLI contract). New i18n key
  MsgRestoreNewModeRejectsTenantID (en/vi/zh).
- security/ssrf: allowLoopbackForTest switched to atomic.Bool so
  concurrent reads from outbound dialers are race-safe.
- Polish: vi backup.json key order matches en/zh; TenantRestoreOptions.Mode
  doc comment documents upsert/replace/new semantics including clone
  behavior for new.
- Tests: unit coverage for validator (12 cases), HTTP guardrails
  (3 cases), shouldRestoreTable replace branch. Integration test
  tests/integration/tenant_restore_replace_test.go regression-guards
  the FK fix using activity_logs seed + DeleteTenantDataForTest helper.

---------

Co-authored-by: Viet Tran <viettranx@gmail.com>
2026-04-16 15:49:50 +07:00

505 lines
15 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ─── test setup ──────────────────────────────────────────────────────────────
func newHookTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := OpenDB(filepath.Join(t.TempDir(), "hooks_test.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
if err := EnsureSchema(db); err != nil {
db.Close()
t.Fatalf("EnsureSchema: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// seedHookTenantAgent inserts a minimal tenant + agent for FK satisfaction.
func seedHookTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
t.Helper()
tenantID = uuid.Must(uuid.NewV7())
agentID = uuid.Must(uuid.NewV7())
_, err := db.Exec(
`INSERT INTO tenants (id, name, slug, status) VALUES (?,?,?,'active')`,
tenantID.String(), "hook-test-"+tenantID.String()[:8], "ht"+tenantID.String()[:8])
if err != nil {
t.Fatalf("seed tenant: %v", err)
}
_, err = db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?,?,?,'predefined','active','test','test-model','owner')`,
agentID.String(), tenantID.String(), "ha-"+agentID.String()[:8])
if err != nil {
t.Fatalf("seed agent: %v", err)
}
return tenantID, agentID
}
func sqliteTenantCtx(tenantID uuid.UUID) context.Context {
return store.WithTenantID(context.Background(), tenantID)
}
func sqliteMasterCtx() context.Context {
return store.WithTenantID(context.Background(), store.MasterTenantID)
}
func sqliteMinimalHook(tenantID uuid.UUID, event hooks.HookEvent) hooks.HookConfig {
return hooks.HookConfig{
TenantID: tenantID,
Event: event,
HandlerType: hooks.HandlerCommand,
Scope: hooks.ScopeTenant,
Config: map[string]any{"cmd": "echo ok"},
Metadata: map[string]any{},
TimeoutMS: 5000,
OnTimeout: hooks.DecisionBlock,
Source: "api",
Enabled: true,
Priority: 0,
}
}
// ─── CRUD ────────────────────────────────────────────────────────────────────
func TestSQLiteHookStore_CRUD(t *testing.T) {
db := newHookTestDB(t)
tenantID, _ := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctx := sqliteTenantCtx(tenantID)
// Create
cfg := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
id, err := s.Create(ctx, cfg)
if err != nil {
t.Fatalf("Create: %v", err)
}
if id == uuid.Nil {
t.Fatal("Create returned nil UUID")
}
// GetByID
got, err := s.GetByID(ctx, id)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got == nil {
t.Fatal("GetByID returned nil for existing hook")
}
if got.Event != hooks.EventPreToolUse {
t.Errorf("event mismatch: got %q want %q", got.Event, hooks.EventPreToolUse)
}
if got.TenantID != tenantID {
t.Errorf("tenant_id mismatch: got %s want %s", got.TenantID, tenantID)
}
if got.Version != 1 {
t.Errorf("initial version should be 1, got %d", got.Version)
}
if !got.Enabled {
t.Error("hook should be enabled")
}
if len(got.Config) == 0 {
t.Error("config should not be empty")
}
// GetByID — not found returns (nil, nil)
missing, err := s.GetByID(ctx, uuid.Must(uuid.NewV7()))
if err != nil {
t.Fatalf("GetByID(missing): unexpected error %v", err)
}
if missing != nil {
t.Fatal("GetByID(missing): expected nil")
}
// Update — bumps version
if err := s.Update(ctx, id, map[string]any{"priority": 10}); err != nil {
t.Fatalf("Update: %v", err)
}
updated, err := s.GetByID(ctx, id)
if err != nil {
t.Fatalf("GetByID after update: %v", err)
}
if updated.Priority != 10 {
t.Errorf("priority not updated: got %d want 10", updated.Priority)
}
if updated.Version != 2 {
t.Errorf("version should be 2 after update, got %d", updated.Version)
}
// Update — reject 'version' key
if err := s.Update(ctx, id, map[string]any{"version": 99}); err == nil {
t.Fatal("Update with 'version' key should return error")
}
// Delete
if err := s.Delete(ctx, id); err != nil {
t.Fatalf("Delete: %v", err)
}
afterDelete, err := s.GetByID(ctx, id)
if err != nil || afterDelete != nil {
t.Fatalf("GetByID after Delete: want (nil,nil), got (%v,%v)", afterDelete, err)
}
}
// ─── Tenant isolation ─────────────────────────────────────────────────────────
func TestSQLiteHookStore_TenantIsolation(t *testing.T) {
db := newHookTestDB(t)
tenantA, _ := seedHookTenantAgent(t, db)
tenantB, _ := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctxA := sqliteTenantCtx(tenantA)
ctxB := sqliteTenantCtx(tenantB)
// Create hook for tenant A.
idA, err := s.Create(ctxA, sqliteMinimalHook(tenantA, hooks.EventStop))
if err != nil {
t.Fatalf("Create A: %v", err)
}
got, err := s.GetByID(ctxB, idA)
if err != nil {
t.Fatalf("GetByID B: %v", err)
}
if got != nil {
t.Errorf("tenant B saw tenant A hook %s in GetByID", idA)
}
gotMaster, err := s.GetByID(sqliteMasterCtx(), idA)
if err != nil {
t.Fatalf("GetByID master: %v", err)
}
if gotMaster == nil {
t.Fatal("master scope should see tenant A hook in GetByID")
}
// List from tenant B must not include tenant A's hook.
listB, err := s.List(ctxB, hooks.ListFilter{})
if err != nil {
t.Fatalf("List B: %v", err)
}
for _, h := range listB {
if h.ID == idA {
t.Errorf("tenant B saw tenant A hook %s", idA)
}
}
// List from tenant A must include their own hook.
listA, err := s.List(ctxA, hooks.ListFilter{})
if err != nil {
t.Fatalf("List A: %v", err)
}
found := false
for _, h := range listA {
if h.ID == idA {
found = true
}
}
if !found {
t.Error("tenant A did not see their own hook in List")
}
// Tenant B cannot delete tenant A's hook.
if err := s.Delete(ctxB, idA); err == nil {
t.Error("tenant B should not be able to delete tenant A's hook")
}
// Cleanup.
_ = s.Delete(ctxA, idA)
}
// ─── Partial unique indexes ───────────────────────────────────────────────────
// H9 (Phase 03): Create honors caller-supplied cfg.ID so the builtin seeder's
// idempotent UUIDv5 keys survive restarts and tests get deterministic IDs.
func TestSQLiteHookStore_CreateHonorsFixedID(t *testing.T) {
db := newHookTestDB(t)
tenantID, _ := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctx := sqliteTenantCtx(tenantID)
fixed := uuid.MustParse("11111111-2222-3333-4444-555555555555")
cfg := sqliteMinimalHook(tenantID, hooks.EventUserPromptSubmit)
cfg.ID = fixed
cfg.Scope = hooks.ScopeTenant
got, err := s.Create(ctx, cfg)
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), got) })
if got != fixed {
t.Fatalf("Create returned id=%s, want %s (H9: caller id must be honored)", got, fixed)
}
// A nil cfg.ID still auto-generates.
cfg2 := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
cfg2.ID = uuid.Nil
auto, err := s.Create(ctx, cfg2)
if err != nil {
t.Fatalf("Create auto: %v", err)
}
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), auto) })
if auto == uuid.Nil {
t.Fatal("Create returned nil id for cfg.ID=uuid.Nil path")
}
}
// ─── ResolveForEvent ordering ────────────────────────────────────────────────
func TestSQLiteHookStore_ResolveForEvent(t *testing.T) {
db := newHookTestDB(t)
tenantID, agentID := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctx := sqliteTenantCtx(tenantID)
// Insert two enabled hooks at different priorities.
lo := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
lo.Priority = 0
hi := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
hi.Priority = 20
// Make hi a different handler_type to avoid unique index conflict.
hi.HandlerType = hooks.HandlerHTTP
idLo, _ := s.Create(ctx, lo)
idHi, _ := s.Create(ctx, hi)
t.Cleanup(func() {
s.Delete(sqliteMasterCtx(), idLo)
s.Delete(sqliteMasterCtx(), idHi)
})
event := hooks.Event{
TenantID: tenantID,
AgentID: agentID,
HookEvent: hooks.EventPreToolUse,
}
resolved, err := s.ResolveForEvent(ctx, event)
if err != nil {
t.Fatalf("ResolveForEvent: %v", err)
}
if len(resolved) < 2 {
t.Fatalf("expected >=2 resolved hooks, got %d", len(resolved))
}
// Highest priority first.
if resolved[0].Priority < resolved[1].Priority {
t.Errorf("wrong order: [0].priority=%d < [1].priority=%d",
resolved[0].Priority, resolved[1].Priority)
}
// Disabled hook must not appear.
dis := sqliteMinimalHook(tenantID, hooks.EventPreToolUse)
dis.Enabled = false
dis.HandlerType = hooks.HandlerPrompt // third distinct handler_type
idDis, _ := s.Create(ctx, dis)
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), idDis) })
resolved2, _ := s.ResolveForEvent(ctx, event)
for _, h := range resolved2 {
if h.ID == idDis {
t.Error("disabled hook appeared in ResolveForEvent result")
}
}
}
// ─── WriteExecution dedup ────────────────────────────────────────────────────
func TestSQLiteHookStore_WriteExecution(t *testing.T) {
db := newHookTestDB(t)
tenantID, _ := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctx := sqliteTenantCtx(tenantID)
// Create a parent hook for FK.
hookID, err := s.Create(ctx, sqliteMinimalHook(tenantID, hooks.EventStop))
if err != nil {
t.Fatalf("Create hook: %v", err)
}
execID := uuid.Must(uuid.NewV7())
dedup := "sqlite-dedup-" + execID.String()[:8]
exec := hooks.HookExecution{
ID: execID,
HookID: &hookID,
SessionID: "sess-sqlite-001",
Event: hooks.EventStop,
InputHash: "deadbeef",
Decision: hooks.DecisionAllow,
DurationMS: 7,
DedupKey: dedup,
Metadata: map[string]any{"src": "test"},
CreatedAt: time.Now().UTC(),
}
// First insert must succeed.
if err := s.WriteExecution(ctx, exec); err != nil {
t.Fatalf("WriteExecution: %v", err)
}
// Duplicate dedup_key must be silently ignored.
if err := s.WriteExecution(ctx, exec); err != nil {
t.Fatalf("WriteExecution (dedup): %v", err)
}
// Verify exactly one row.
var count int
if err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM hook_executions WHERE dedup_key=?", dedup,
).Scan(&count); err != nil {
t.Fatalf("count executions: %v", err)
}
if count != 1 {
t.Errorf("expected 1 execution row, got %d", count)
}
}
// ─── Cache invalidation ───────────────────────────────────────────────────────
func TestSQLiteHookStore_CacheInvalidatedOnWrite(t *testing.T) {
db := newHookTestDB(t)
tenantID, agentID := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
ctx := sqliteTenantCtx(tenantID)
event := hooks.Event{
TenantID: tenantID,
AgentID: agentID,
HookEvent: hooks.EventSessionStart,
}
// Warm cache.
before, err := s.ResolveForEvent(ctx, event)
if err != nil {
t.Fatalf("ResolveForEvent: %v", err)
}
beforeCount := len(before)
// Create a new hook — must invalidate cache.
id, err := s.Create(ctx, sqliteMinimalHook(tenantID, hooks.EventSessionStart))
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), id) })
after, err := s.ResolveForEvent(ctx, event)
if err != nil {
t.Fatalf("ResolveForEvent after create: %v", err)
}
if len(after) <= beforeCount {
t.Errorf("cache not invalidated: before=%d after=%d", beforeCount, len(after))
}
}
// ─── Global-scope hooks visible to all tenants ────────────────────────────────
func TestSQLiteHookStore_GlobalScopeVisibleToTenant(t *testing.T) {
db := newHookTestDB(t)
tenantID, agentID := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
// Create a global hook under MasterTenantID.
globalCfg := hooks.HookConfig{
TenantID: store.MasterTenantID,
Event: hooks.EventPostToolUse,
HandlerType: hooks.HandlerCommand,
Scope: hooks.ScopeGlobal,
Config: map[string]any{"cmd": "audit.sh"},
Metadata: map[string]any{},
TimeoutMS: 3000,
OnTimeout: hooks.DecisionAllow,
Source: "seed",
Enabled: true,
Priority: 5,
}
globalID, err := s.Create(sqliteMasterCtx(), globalCfg)
if err != nil {
t.Fatalf("Create global hook: %v", err)
}
t.Cleanup(func() { s.Delete(sqliteMasterCtx(), globalID) })
// ResolveForEvent from tenant scope must include the global hook.
event := hooks.Event{
TenantID: tenantID,
AgentID: agentID,
HookEvent: hooks.EventPostToolUse,
}
resolved, err := s.ResolveForEvent(sqliteTenantCtx(tenantID), event)
if err != nil {
t.Fatalf("ResolveForEvent: %v", err)
}
found := false
for _, h := range resolved {
if h.ID == globalID {
found = true
}
}
if !found {
t.Error("global hook not visible in ResolveForEvent from tenant scope")
}
}
// TestSQLiteHookStore_BuiltinReadOnly mirrors the PG test: user-facing writes
// on source='builtin' rows may only toggle enabled; WithSeedBypass unlocks.
func TestSQLiteHookStore_BuiltinReadOnly(t *testing.T) {
db := newHookTestDB(t)
tenantID, _ := seedHookTenantAgent(t, db)
s := NewSQLiteHookStore(db)
seedCtx := hooks.WithSeedBypass(store.WithRole(sqliteMasterCtx(), store.RoleOwner))
cfg := hooks.HookConfig{
ID: uuid.MustParse("11111111-2222-3333-4444-555555555555"),
TenantID: hooks.SentinelTenantID,
Event: hooks.EventUserPromptSubmit,
HandlerType: hooks.HandlerScript,
Scope: hooks.ScopeGlobal,
Config: map[string]any{"source": "// v1"},
Metadata: map[string]any{"builtin": true, "version": 1},
TimeoutMS: 500,
OnTimeout: hooks.DecisionAllow,
Source: hooks.SourceBuiltin,
Enabled: true,
}
id, err := s.Create(seedCtx, cfg)
if err != nil {
t.Fatalf("seed create: %v", err)
}
t.Cleanup(func() { s.Delete(seedCtx, id) })
userCtx := sqliteTenantCtx(tenantID)
if err := s.Update(userCtx, id, map[string]any{"matcher": "evil"}); !errors.Is(err, hooks.ErrBuiltinReadOnly) {
t.Fatalf("Update(matcher) err=%v, want ErrBuiltinReadOnly", err)
}
if err := s.Update(sqliteMasterCtx(), id, map[string]any{"enabled": false}); err != nil {
t.Fatalf("Update(enabled) should succeed: %v", err)
}
if err := s.Delete(userCtx, id); !errors.Is(err, hooks.ErrBuiltinReadOnly) {
t.Fatalf("Delete user err=%v, want ErrBuiltinReadOnly", err)
}
if err := s.Update(seedCtx, id, map[string]any{"matcher": "ok"}); err != nil {
t.Fatalf("seed-bypass Update should succeed: %v", err)
}
}