Files
goclaw/tests/integration/tenant_restore_replace_test.go
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

121 lines
4.7 KiB
Go

//go:build integration
package integration
import (
"context"
"database/sql"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/backup"
)
// TestTenantRestore_DeleteTenantData_PreservesTenantsRow verifies that
// replace mode's child-cleanup path does NOT delete the tenants row itself.
//
// Prior to PR #920 follow-up fix, deleteTenantData generated
// `DELETE FROM tenants WHERE id = $1` as the final iteration. This fails
// under PG FK restrict for any tenant that has rows in diagnostic tables
// (traces, activity_logs, usage_snapshots, spans, embedding_cache, etc.)
// because those tables reference tenants(id) without ON DELETE CASCADE.
//
// This regression guard seeds activity_logs (a table excluded from the
// backup registry) and then invokes the replace-mode cleanup path. It
// must complete without FK violations AND leave the tenants row intact.
func TestTenantRestore_DeleteTenantData_PreservesTenantsRow(t *testing.T) {
db := testDB(t)
tenantID, agent1ID := seedTenantAgent(t, db)
// Capture tenant metadata.
var tenantSlugBefore, tenantNameBefore string
if err := db.QueryRow(`SELECT slug, name FROM tenants WHERE id = $1`, tenantID).
Scan(&tenantSlugBefore, &tenantNameBefore); err != nil {
t.Fatalf("fetch tenant: %v", err)
}
// Seed diagnostic rows that are NOT in TenantTables() (excluded intentionally).
// These would cause FK violation on DELETE FROM tenants if the fix is missing.
seedActivityLog(t, db, tenantID, "pre-delete-1")
seedActivityLog(t, db, tenantID, "pre-delete-2")
t.Cleanup(func() {
db.Exec(`DELETE FROM activity_logs WHERE tenant_id = $1`, tenantID)
})
// Add a second agent — this is the target of the cleanup.
agent2ID := uuid.New()
_, 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')`,
agent2ID, tenantID, "extra-"+agent2ID.String()[:8])
if err != nil {
t.Fatalf("insert agent2: %v", err)
}
t.Cleanup(func() {
db.Exec(`DELETE FROM agents WHERE id = $1`, agent2ID)
})
// Invoke the replace-mode cleanup path directly via the exported helper.
// Before the fix: this errors with "update or delete on table \"tenants\"
// violates foreign key constraint ... on table \"activity_logs\"".
// After the fix: completes cleanly, agents wiped, tenants row preserved.
tables := backup.TenantTables()
if err := backup.DeleteTenantDataForTest(context.Background(), db, tenantID, tables); err != nil {
// Surface FK violation clearly if the fix regresses.
if strings.Contains(err.Error(), "foreign key") || strings.Contains(err.Error(), "tenants") {
t.Fatalf("replace cleanup hit FK violation (fix regressed): %v", err)
}
t.Fatalf("deleteTenantData: %v", err)
}
// Tenants row preserved with original metadata.
var slugAfter, nameAfter string
if err := db.QueryRow(`SELECT slug, name FROM tenants WHERE id = $1`, tenantID).
Scan(&slugAfter, &nameAfter); err != nil {
if err == sql.ErrNoRows {
t.Fatal("tenants row was deleted — fix regressed")
}
t.Fatalf("fetch tenant post-cleanup: %v", err)
}
if slugAfter != tenantSlugBefore || nameAfter != tenantNameBefore {
t.Errorf("tenants metadata changed: slug=%q name=%q, want slug=%q name=%q",
slugAfter, nameAfter, tenantSlugBefore, tenantNameBefore)
}
// All child agents wiped.
var agentCount int
db.QueryRow(`SELECT COUNT(*) FROM agents WHERE tenant_id = $1`, tenantID).Scan(&agentCount)
if agentCount != 0 {
t.Errorf("agents after cleanup = %d, want 0 (all children wiped)", agentCount)
}
// agent1 also gone — replace cleanup is exhaustive for child tables.
var hasAgent1 bool
db.QueryRow(`SELECT EXISTS(SELECT 1 FROM agents WHERE id = $1)`, agent1ID).Scan(&hasAgent1)
if hasAgent1 {
t.Error("agent1 should be wiped by replace cleanup")
}
// Diagnostic rows untouched (excluded from backup registry).
var logCount int
db.QueryRow(`SELECT COUNT(*) FROM activity_logs WHERE tenant_id = $1`, tenantID).Scan(&logCount)
if logCount != 2 {
t.Errorf("activity_logs = %d, want 2 (excluded from cleanup)", logCount)
}
}
// seedActivityLog inserts a minimal activity_logs row scoped to tenantID.
// activity_logs is intentionally excluded from TenantTables(), so it exercises
// the FK-to-tenants-without-CASCADE path.
func seedActivityLog(t *testing.T, db *sql.DB, tenantID uuid.UUID, note string) {
t.Helper()
_, err := db.Exec(`
INSERT INTO activity_logs (id, tenant_id, actor_type, actor_id, action, entity_type, entity_id)
VALUES ($1, $2, 'user', 'test-user', 'test', 'test', $3)`,
uuid.New(), tenantID, note)
if err != nil {
t.Fatalf("seed activity_log: %v", err)
}
}