374 lines
13 KiB
Go
374 lines
13 KiB
Go
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("缺少工具时不应进入锁和数据库恢复阶段")
|
|
}
|
|
}
|