mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
* 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>
121 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|