Scert/backend/internal/cli/postgres_integration_test.go

257 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}