package cli import ( "bytes" "context" "errors" "io" "os" "path/filepath" "slices" "strings" "testing" "time" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/secretstore" ) // 替身只模拟工具协议和数据库状态,不构成 PostgreSQL 实机恢复证据。 type postgresFixture struct { t *testing.T state string restores int failDump bool failDecode bool failRestore bool failRollback bool extraObject bool cancelRestore context.CancelFunc tools *postgresTools } func newPostgresFixture(t *testing.T) *postgresFixture { f := &postgresFixture{t: t, state: "current"} f.tools = &postgresTools{dumpPath: "dump", restorePath: "restore", run: f.run} return f } func (f *postgresFixture) factory(context.Context) (*postgresTools, error) { return f.tools, nil } func (f *postgresFixture) run(ctx context.Context, tool string, args, env []string, output io.Writer) error { f.t.Helper() if err := ctx.Err(); err != nil { return err } connected := slices.ContainsFunc(args, func(arg string) bool { return strings.HasPrefix(arg, "--dbname=") }) if connected { if !slices.Contains(env, "PGPASSWORD=current-password") || strings.Contains(strings.Join(args, " "), "current-password") || strings.Contains(strings.Join(args, " "), "backup-password") { f.t.Fatal("维护操作必须固定当前凭据且不将密码放入命令行") } } if tool == "dump" { if !slices.Contains(args, "--format=custom") || !slices.Contains(args, "--no-password") || slices.Contains(args, "--no-acl") || slices.Contains(args, "--no-owner") { f.t.Fatal("数据库快照工具参数不完整") } _, err := io.WriteString(output, "PGDMP"+f.state) if f.failDump { return errors.New("模拟快照失败") } return err } input := args[len(args)-1] data, err := os.ReadFile(input) if err != nil { return err } if slices.Contains(args, "--list") { _, err := io.WriteString(output, "; Archive TOC\n10; 1259 100 TABLE public settings owner\n11; 0 100 TABLE DATA public settings owner\n") if f.extraObject && strings.HasPrefix(string(data), "PGDMPcurrent") { _, err = io.WriteString(output, "12; 1259 101 TABLE public extra owner\n") } return err } if !connected { if !slices.Contains(args, "--file=-") || f.failDecode { return errors.New("模拟归档解码失败") } return nil } if slices.Contains(args, "--no-owner") || slices.Contains(args, "--no-acl") { f.t.Fatal("完整恢复不能静默丢弃所有者或对象授权") } for _, flag := range []string{"--clean", "--if-exists", "--single-transaction", "--exit-on-error", "--no-password"} { if !slices.Contains(args, flag) { f.t.Fatalf("恢复缺少必要参数:%s", flag) } } f.restores++ if f.restores > 1 { deadline, ok := ctx.Deadline() if !ok || time.Until(deadline) > 5*time.Minute || ctx.Err() != nil { f.t.Fatal("回滚上下文必须独立且有界") } if f.failRollback { return errors.New("模拟数据库回滚失败") } } f.state = strings.TrimPrefix(string(data), "PGDMP") if f.restores == 1 && f.cancelRestore != nil { f.cancelRestore() return context.Canceled } if f.restores == 1 && f.failRestore { return errors.New("模拟导入失败") } return nil } func preparePostgresRestoreFixture(t *testing.T, blockedCertificate bool) (config.Config, string, string) { t.Helper() root := t.TempDir() cfg := config.Default(root) cfg.Paths = config.PathsConfig{ Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"), Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "backups"), } cfg.Database = config.DatabaseConfig{ Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/maintenance", MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30, } cfg.Server.Port = 9004 current := filepath.Join(cfg.Paths.Config, "scert.json") if err := cfg.Save(current); err != nil { t.Fatal(err) } input := filepath.Join(root, "input") backup := cfg backup.Server.Port = 9005 if err := backup.Save(filepath.Join(input, "scert.json")); err != nil { t.Fatal(err) } for directory, password := range map[string]string{cfg.Paths.Config: "current-password", input: "backup-password"} { store, err := secretstore.Open(directory) if err != nil { t.Fatal(err) } if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "owner", "password": password}); err != nil { t.Fatal(err) } } if blockedCertificate { if err := os.WriteFile(cfg.Paths.Certificate, []byte("preserve-blocked-target"), 0o600); err != nil { t.Fatal(err) } } else { if err := os.MkdirAll(cfg.Paths.Certificate, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(cfg.Paths.Certificate, "old.pem"), []byte("old-certificate"), 0o600); err != nil { t.Fatal(err) } } if err := os.MkdirAll(filepath.Join(input, "certificates"), 0o700); err != nil { t.Fatal(err) } for path, value := range map[string]string{ filepath.Join(input, "scert.pgdump"): "PGDMPrestored", filepath.Join(input, "certificates", "new.pem"): "new-certificate", } { if err := os.WriteFile(path, []byte(value), 0o600); err != nil { t.Fatal(err) } } if err := writeBackupManifest(input); err != nil { t.Fatal(err) } previous := configPath configPath = current t.Cleanup(func() { configPath = previous }) return cfg, current, input } func TestPostgresCompleteBackupAndFailedPublication(t *testing.T) { for _, failure := range []string{"", "dump", "decode"} { t.Run("failure="+failure, func(t *testing.T) { cfg, current, _ := preparePostgresRestoreFixture(t, false) f := newPostgresFixture(t) f.failDump, f.failDecode = failure == "dump", failure == "decode" output := filepath.Join(t.TempDir(), "backup") err := createBackupWithPostgresTools(t.Context(), cfg, current, output, f.factory) if failure != "" { if err == nil { t.Fatal("快照或解码失败时不能报告备份成功") } if _, err := os.Stat(output); !os.IsNotExist(err) { t.Fatal("失败备份不能发布") } return } if err != nil { t.Fatal(err) } if err := validateBackupManifest(output); err != nil { t.Fatal(err) } for _, name := range []string{"scert.pgdump", "scert.json", "master.key", "secrets.json", "certificates/old.pem"} { if !isRegularFile(filepath.Join(output, name)) { t.Fatalf("完整备份缺少:%s", name) } } data, err := os.ReadFile(filepath.Join(output, "scert.json")) if err != nil || bytes.Contains(data, []byte("current-password")) { t.Fatal("备份配置不得写入内存连接密码") } }) } } func TestPostgresRestoreSuccessAndRollback(t *testing.T) { for _, scenario := range []string{"success", "certificate", "import", "cancel", "rollback", "inventory", "snapshot", "decode"} { t.Run(scenario, func(t *testing.T) { cfg, current, input := preparePostgresRestoreFixture(t, scenario == "certificate") before, _ := os.ReadFile(current) beforeKey, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "master.key")) beforeSecrets, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "secrets.json")) f := newPostgresFixture(t) f.failRestore = scenario == "import" || scenario == "rollback" f.failRollback, f.extraObject = scenario == "rollback", scenario == "inventory" f.failDump, f.failDecode = scenario == "snapshot", scenario == "decode" ctx, cancel := context.WithCancel(t.Context()) defer cancel() if scenario == "cancel" { f.cancelRestore = cancel } command := newRestoreCommandWithPostgresTools(f.factory) command.SetArgs([]string{"--input", input}) command.SetContext(ctx) var output bytes.Buffer command.SetOut(&output) command.SetErr(&output) err := command.Execute() if scenario == "success" { if err != nil || f.state != "restored" || f.restores != 1 { t.Fatalf("完整恢复未成功:%v", err) } loaded, err := config.Load(current) if err != nil || loaded.Server.Port != 9005 || !isRegularFile(filepath.Join(cfg.Paths.Certificate, "new.pem")) { t.Fatal("恢复未包含配置或证书") } store, err := secretstore.Open(cfg.Paths.Config) if err != nil { t.Fatal(err) } credentials, err := store.Get(t.Context(), cfg.Database.CredentialRef) if err != nil || credentials["password"] != "backup-password" { t.Fatal("备份加密凭据未恢复") } } else { if err == nil || strings.Contains(output.String(), "恢复已完成") { t.Fatal("恢复失败不能报告成功") } if scenario == "rollback" { if !strings.Contains(err.Error(), "数据库自动回滚失败") { t.Fatal("回滚失败必须明确要求人工恢复") } } else if f.state != "current" { t.Fatal("失败时没有恢复原数据库状态") } after, _ := os.ReadFile(current) afterKey, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "master.key")) afterSecrets, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "secrets.json")) if !bytes.Equal(before, after) || !bytes.Equal(beforeKey, afterKey) || !bytes.Equal(beforeSecrets, afterSecrets) { t.Fatal("失败时未保留或回滚原配置与加密凭据") } if scenario == "inventory" || scenario == "snapshot" || scenario == "decode" { if f.restores != 0 { t.Fatal("预检查失败时不应执行数据库导入") } } else if f.restores != 2 { t.Fatal("导入后失败应执行数据库回滚") } } if scenario != "snapshot" && scenario != "decode" { matches, _ := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-postgres-before-restore-*", "scert.pgdump")) if len(matches) != 1 { t.Fatal("恢复前快照应保留供管理员检查") } } }) } } func TestPostgresRestoreRejectsDifferentDatabase(t *testing.T) { current := config.Config{Database: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://db.test/current?sslmode=verify-full"}} backup := current backup.Database.DSN = "postgres://db.test/other?sslmode=verify-full" if err := validateRestoreCompatibility(current, backup); err == nil || !strings.Contains(err.Error(), "拒绝跨数据库恢复") { t.Fatal("恢复不得连接不同目标数据库") } } func TestPostgresBackupDoesNotOverwriteExistingFile(t *testing.T) { cfg, _, _ := preparePostgresRestoreFixture(t, false) connection, err := resolveMaintenanceDatabase(t.Context(), cfg.Database, cfg.Paths.Config) if err != nil { t.Fatal(err) } output := filepath.Join(t.TempDir(), "existing.pgdump") if err := os.WriteFile(output, []byte("existing-backup"), 0o600); err != nil { t.Fatal(err) } f := newPostgresFixture(t) if err := f.tools.backup(t.Context(), connection, output); err == nil { t.Fatal("既有文件不能被覆盖") } content, err := os.ReadFile(output) if err != nil || string(content) != "existing-backup" { t.Fatal("拒绝覆盖后必须保留原文件") } } func TestPostgresArchiveRejectsInvalidMagicAndInventory(t *testing.T) { input := filepath.Join(t.TempDir(), "scert.pgdump") if err := os.WriteFile(input, []byte("not-a-custom-archive"), 0o600); err != nil { t.Fatal(err) } f := newPostgresFixture(t) if err := f.tools.validateArchive(t.Context(), input); err == nil { t.Fatal("非自定义格式归档必须拒绝") } for _, listing := range []string{"", "; empty", "malformed", "1; 1259 1 TABLE public a owner\n2; 1259 2 TABLE public a owner\n", strings.Repeat("x", (8<<20)+1)} { tools := &postgresTools{restorePath: "restore", run: func(_ context.Context, _ string, _, _ []string, output io.Writer) error { _, err := io.WriteString(output, listing) return err }} if _, err := tools.inventory(t.Context(), input); err == nil { t.Fatal("空、畸形、重复或超限清单必须拒绝") } } } func TestPostgresCanceledBackupIsNotPublished(t *testing.T) { cfg, current, _ := preparePostgresRestoreFixture(t, false) f := newPostgresFixture(t) ctx, cancel := context.WithCancel(t.Context()) defer cancel() f.tools.run = func(ctx context.Context, tool string, args, env []string, output io.Writer) error { err := f.run(ctx, tool, args, env, output) if tool == "restore" && slices.Contains(args, "--file=-") { cancel() } return err } output := filepath.Join(t.TempDir(), "backup") if err := createBackupWithPostgresTools(ctx, cfg, current, output, f.factory); !errors.Is(err, context.Canceled) { t.Fatalf("完成快照后取消仍应停止发布:%v", err) } if _, err := os.Stat(output); !os.IsNotExist(err) { t.Fatal("取消的备份不能发布") } } func TestPostgresMissingRestoreToolsPreserveCurrentAssets(t *testing.T) { cfg, current, input := preparePostgresRestoreFixture(t, false) before, _ := os.ReadFile(current) factory := func(context.Context) (*postgresTools, error) { return nil, errors.New("模拟官方工具未安装") } command := newRestoreCommandWithPostgresTools(factory) command.SetArgs([]string{"--input", input}) command.SetOut(io.Discard) command.SetErr(io.Discard) if err := command.Execute(); err == nil { t.Fatal("缺少工具不能恢复") } after, _ := os.ReadFile(current) if !bytes.Equal(before, after) || !isRegularFile(filepath.Join(cfg.Paths.Certificate, "old.pem")) { t.Fatal("缺少工具不得改变当前配置或证书") } if _, err := os.Stat(cfg.Paths.Data); !os.IsNotExist(err) { t.Fatal("缺少工具时不应进入锁和数据库恢复阶段") } }