257 lines
10 KiB
Go
257 lines
10 KiB
Go
package cli
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||
)
|
||
|
||
func TestPostgresRealBackupRestore(t *testing.T) {
|
||
f := startRealPostgres(t)
|
||
for _, scenario := range []string{"success", "certificate", "cancel", "permission", "inventory", "authentication", "tls"} {
|
||
t.Run(scenario, func(t *testing.T) {
|
||
dbName := "scert_" + scenario
|
||
realPostgresExec(t, f.admin, "CREATE DATABASE "+dbName+" OWNER scert_owner")
|
||
conn := f.connect(t, dbName, "scert_owner")
|
||
var tls bool
|
||
if err := conn.QueryRow(t.Context(), "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()").Scan(&tls); err != nil || !tls {
|
||
t.Fatal("真实数据库连接必须启用 TLS")
|
||
}
|
||
root := filepath.Join(f.root, scenario)
|
||
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, "snapshots"),
|
||
}
|
||
cfg.Database = config.DatabaseConfig{Driver: "postgres", DSN: f.dsn(dbName, ""), CredentialRef: "secret://database/maintenance", MaxOpenConns: 4, MaxIdleConns: 2}
|
||
current := filepath.Join(cfg.Paths.Config, "scert.json")
|
||
if err := cfg.Save(current); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
store, err := secretstore.Open(cfg.Paths.Config)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "scert_owner", "password": f.password}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
connection, err := resolveMaintenanceDatabase(t.Context(), cfg.Database, cfg.Paths.Config)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db, err := database.Open(t.Context(), connection)
|
||
if err != nil {
|
||
t.Fatal("真实数据库迁移连接失败")
|
||
}
|
||
err = database.Migrate(t.Context(), db.DB, "postgres")
|
||
_ = db.Close()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
realPostgresExec(t, conn, "INSERT INTO settings (key, value) VALUES ('integration', 'backup')")
|
||
realPostgresExec(t, conn, "INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('backup', 'test', '1', 'ok', 'backup')")
|
||
realPostgresExec(t, conn, "GRANT SELECT ON settings TO scert_reader")
|
||
if scenario == "permission" {
|
||
admin := f.connect(t, dbName, "postgres")
|
||
realPostgresExec(t, admin, "CREATE TABLE protected_record (value TEXT)")
|
||
realPostgresExec(t, admin, "INSERT INTO protected_record VALUES ('preserved')")
|
||
realPostgresExec(t, admin, "GRANT SELECT ON protected_record TO scert_owner")
|
||
}
|
||
if err := os.MkdirAll(cfg.Paths.Certificate, 0o700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
certificate := filepath.Join(cfg.Paths.Certificate, "integration.pem")
|
||
certData, err := os.ReadFile(f.ca)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
realPostgresWrite(t, certificate, certData)
|
||
previous := configPath
|
||
configPath = current
|
||
t.Cleanup(func() { configPath = previous })
|
||
input := filepath.Join(root, "backup")
|
||
if _, err := f.runCLI(t, current, "backup", "--output", input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := validateBackupManifest(input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if scenario == "authentication" || scenario == "tls" {
|
||
if scenario == "authentication" {
|
||
if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "scert_owner", "password": "incorrect-password"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
} else {
|
||
// 使用不信任服务端的独立 CA,验证不能静默降级 TLS。
|
||
wrongCA := filepath.Join(root, "untrusted.crt")
|
||
realPostgresTLS(t, wrongCA, filepath.Join(root, "untrusted.key"))
|
||
cfg.Database.DSN = postgresIntegrationCA(t, cfg.Database.DSN, wrongCA)
|
||
if err := cfg.Save(current); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
output := filepath.Join(root, "rejected-backup")
|
||
if _, err := f.runCLI(t, current, "backup", "--output", output); err == nil || strings.Contains(err.Error(), f.password) {
|
||
t.Fatal("认证或 TLS 错误必须拒绝且不得泄漏密码")
|
||
}
|
||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||
t.Fatal("认证或 TLS 失败不得发布备份")
|
||
}
|
||
return
|
||
}
|
||
realPostgresExec(t, conn, "UPDATE settings SET value = 'current' WHERE key = 'integration'")
|
||
realPostgresExec(t, conn, "INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('current', 'test', '2', 'ok', 'current')")
|
||
cfg.Server.Port++
|
||
if err := cfg.Save(current); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := store.Put(t.Context(), "secret://integration/marker", map[string]string{"value": "current"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
realPostgresWrite(t, certificate, []byte("current-certificate"))
|
||
if scenario == "certificate" {
|
||
if err := os.Rename(cfg.Paths.Certificate, cfg.Paths.Certificate+"-preserved"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
realPostgresWrite(t, cfg.Paths.Certificate, []byte("blocked-target"))
|
||
}
|
||
if scenario == "inventory" {
|
||
realPostgresExec(t, conn, "CREATE TABLE extra_record (value TEXT)")
|
||
}
|
||
before := make(map[string][]byte)
|
||
for _, name := range []string{"scert.json", "master.key", "secrets.json"} {
|
||
before[name], err = os.ReadFile(filepath.Join(cfg.Paths.Config, name))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var output bytes.Buffer
|
||
command := newRestoreCommand()
|
||
ctx, cancel := context.WithCancel(t.Context())
|
||
defer cancel()
|
||
if scenario == "cancel" {
|
||
// 真正导入完成后确定性取消,反向回滚仍执行官方工具和真实数据库。
|
||
command = newRestoreCommandWithPostgresTools(func(ctx context.Context) (*postgresTools, error) {
|
||
tools, err := loadPostgresTools(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tools.run = func(ctx context.Context, path string, args, env []string, output io.Writer) error {
|
||
err := executePostgresTool(ctx, path, args, env, output)
|
||
if err == nil && slices.Contains(args, "--single-transaction") && args[len(args)-1] == filepath.Join(input, "scert.pgdump") {
|
||
cancel()
|
||
}
|
||
return err
|
||
}
|
||
return tools, nil
|
||
})
|
||
}
|
||
command.SetArgs([]string{"--input", input})
|
||
command.SetContext(ctx)
|
||
command.SetOut(&output)
|
||
command.SetErr(&output)
|
||
if scenario == "cancel" {
|
||
err = command.Execute()
|
||
} else {
|
||
var text string
|
||
text, err = f.runCLI(t, current, "restore", "--input", input)
|
||
output.WriteString(text)
|
||
}
|
||
want, count := "backup", 1
|
||
if scenario == "success" {
|
||
if err != nil || !strings.Contains(output.String(), "恢复已完成") {
|
||
t.Fatalf("真实恢复失败:%v", err)
|
||
}
|
||
for _, name := range []string{"scert.json", "master.key", "secrets.json"} {
|
||
assertRealPostgresFile(t, filepath.Join(cfg.Paths.Config, name), filepath.Join(input, name))
|
||
}
|
||
assertRealPostgresFile(t, certificate, filepath.Join(input, "certificates", "integration.pem"))
|
||
} else {
|
||
want, count = "current", 2
|
||
if err == nil || strings.Contains(output.String(), "恢复已完成") || strings.Contains(err.Error(), f.password) {
|
||
t.Fatal("受控失败不得误报成功或泄漏密码")
|
||
}
|
||
if scenario == "permission" && !strings.Contains(err.Error(), "数据库自动回滚失败") {
|
||
t.Fatal("恢复及反向导入权限不足时必须提示人工处理")
|
||
}
|
||
if scenario == "cancel" && !errors.Is(err, context.Canceled) {
|
||
t.Fatal("取消场景必须到达真实导入后的取消检查")
|
||
}
|
||
if scenario == "certificate" && !strings.Contains(err.Error(), "恢复敏感配置或证书失败") {
|
||
t.Fatal("证书失败场景必须到达数据库导入后的资源恢复阶段")
|
||
}
|
||
if scenario == "inventory" && !strings.Contains(err.Error(), "对象清单不一致") {
|
||
t.Fatal("额外对象必须由归档清单检查拦截")
|
||
}
|
||
for name, expected := range before {
|
||
actual, readErr := os.ReadFile(filepath.Join(cfg.Paths.Config, name))
|
||
if readErr != nil || !bytes.Equal(actual, expected) {
|
||
t.Fatalf("失败恢复改变了当前文件:%s", name)
|
||
}
|
||
}
|
||
}
|
||
var value, owner string
|
||
var rows, next int
|
||
var allowed bool
|
||
if err := conn.QueryRow(t.Context(), "SELECT value FROM settings WHERE key = 'integration'").Scan(&value); err != nil || value != want {
|
||
t.Fatal("真实数据库内容不符合恢复或回滚结果")
|
||
}
|
||
if err := conn.QueryRow(t.Context(), "SELECT count(*) FROM audit_logs").Scan(&rows); err != nil || rows != count {
|
||
t.Fatal("真实审计记录未正确恢复")
|
||
}
|
||
if err := conn.QueryRow(t.Context(), "SELECT nextval(pg_get_serial_sequence('audit_logs', 'id'))").Scan(&next); err != nil || next != count+1 {
|
||
t.Fatal("真实自增序列未正确恢复")
|
||
}
|
||
if err := conn.QueryRow(t.Context(), "SELECT tableowner FROM pg_tables WHERE schemaname = 'public' AND tablename = 'settings'").Scan(&owner); err != nil || owner != "scert_owner" {
|
||
t.Fatal("对象所有者未保留")
|
||
}
|
||
if err := conn.QueryRow(t.Context(), "SELECT has_table_privilege('scert_reader', 'settings', 'SELECT')").Scan(&allowed); err != nil || !allowed {
|
||
t.Fatal("对象授权未保留")
|
||
}
|
||
if scenario == "permission" {
|
||
if err := conn.QueryRow(t.Context(), "SELECT value FROM protected_record").Scan(&value); err != nil || value != "preserved" {
|
||
t.Fatal("权限失败不能改变受保护表")
|
||
}
|
||
}
|
||
snapshots, err := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-postgres-before-restore-*", "scert.pgdump"))
|
||
if err != nil || len(snapshots) != 1 {
|
||
t.Fatal("必须保留恢复前真实快照")
|
||
}
|
||
t.Log("真实 CLI、迁移、业务数据、序列、所有者、授权及恢复保护检查通过")
|
||
})
|
||
}
|
||
}
|
||
|
||
func assertRealPostgresFile(t *testing.T, actual, expected string) {
|
||
t.Helper()
|
||
left, leftErr := os.ReadFile(actual)
|
||
right, rightErr := os.ReadFile(expected)
|
||
if leftErr != nil || rightErr != nil || !bytes.Equal(left, right) {
|
||
t.Fatal("真实恢复后的文件与备份不一致")
|
||
}
|
||
}
|
||
|
||
// 保持测试辅助函数与运行时连接校验使用相同的结构化 URL 语义。
|
||
func postgresIntegrationCA(t *testing.T, dsn, ca string) string {
|
||
t.Helper()
|
||
parsed, err := url.Parse(dsn)
|
||
if err != nil {
|
||
t.Fatal("测试连接解析失败")
|
||
}
|
||
query := parsed.Query()
|
||
query.Set("sslrootcert", ca)
|
||
parsed.RawQuery = query.Encode()
|
||
return parsed.String()
|
||
}
|