Files
goclaw/internal/backup/tenant_restore_helpers_test.go
badgerbeesandViet Tran 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

98 lines
2.8 KiB
Go

package backup
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
"github.com/google/uuid"
)
func TestLoadTenantRestoreRow(t *testing.T) {
row, err := loadTenantRestoreRow([]byte(`{"id":"0193a5b0-7000-7000-8000-000000000001","name":"Master","slug":"master","status":"active","settings":"{\"theme\":\"dark\"}"}`))
if err != nil {
t.Fatalf("loadTenantRestoreRow() error = %v", err)
}
if row.Name != "Master" {
t.Fatalf("row.Name = %q, want %q", row.Name, "Master")
}
if row.Slug != "master" {
t.Fatalf("row.Slug = %q, want %q", row.Slug, "master")
}
if row.Status != "active" {
t.Fatalf("row.Status = %q, want %q", row.Status, "active")
}
if row.Settings != `{"theme":"dark"}` {
t.Fatalf("row.Settings = %q, want %q", row.Settings, `{"theme":"dark"}`)
}
}
func TestShouldRestoreTable(t *testing.T) {
if shouldRestoreTable("new", TableDef{Name: "tenants"}) {
t.Fatal("shouldRestoreTable(new, tenants) = true, want false")
}
if shouldRestoreTable("replace", TableDef{Name: "tenants"}) {
t.Fatal("shouldRestoreTable(replace, tenants) = true, want false")
}
if !shouldRestoreTable("upsert", TableDef{Name: "tenants"}) {
t.Fatal("shouldRestoreTable(upsert, tenants) = false, want true")
}
if !shouldRestoreTable("new", TableDef{Name: "agents"}) {
t.Fatal("shouldRestoreTable(new, agents) = false, want true")
}
if !shouldRestoreTable("replace", TableDef{Name: "agents"}) {
t.Fatal("shouldRestoreTable(replace, agents) = false, want true")
}
}
func TestEnsureTenantSlugAvailable(t *testing.T) {
existingID := uuid.MustParse("0193a5b0-7000-7000-8000-000000000002")
tests := []struct {
name string
lookup func(context.Context, string) (uuid.UUID, error)
wantErrContains string
}{
{
name: "available",
lookup: func(context.Context, string) (uuid.UUID, error) {
return uuid.Nil, sql.ErrNoRows
},
},
{
name: "duplicate",
lookup: func(context.Context, string) (uuid.UUID, error) {
return existingID, nil
},
wantErrContains: "already exists",
},
{
name: "lookup failure",
lookup: func(context.Context, string) (uuid.UUID, error) {
return uuid.Nil, errors.New("boom")
},
wantErrContains: "check tenant slug",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ensureTenantSlugAvailable(context.Background(), "new-slug", tt.lookup)
if tt.wantErrContains == "" {
if err != nil {
t.Fatalf("ensureTenantSlugAvailable() error = %v, want nil", err)
}
return
}
if err == nil {
t.Fatal("ensureTenantSlugAvailable() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Fatalf("ensureTenantSlugAvailable() error = %q, want substring %q", err.Error(), tt.wantErrContains)
}
})
}
}