Scert/backend/internal/cli/sqlite_business_restore_test.go

184 lines
6.4 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 (
"crypto/tls"
"os"
"path/filepath"
"reflect"
"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/repository"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/ssh"
)
func TestSQLiteBusinessBackupRestore(t *testing.T) {
fixture := newSQLiteBusinessFixture(t)
cfg := fixture.config
previous := configPath
configPath = fixture.path
t.Cleanup(func() { configPath = previous })
expected := snapshotSQLiteBusiness(t, cfg)
input := filepath.Join(t.TempDir(), "business-backup")
backup := newBackupCommand()
var output strings.Builder
backup.SetOut(&output)
backup.SetErr(&output)
backup.SetArgs([]string{"--output", input})
if err := backup.ExecuteContext(t.Context()); err != nil {
t.Fatal(err)
}
if err := validateBackupManifest(input); err != nil {
t.Fatal(err)
}
// 备份后改变数据库、配置、加密凭据和证书,确保恢复不是空操作。
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, query := range []string{
"UPDATE settings SET value = '变更后'",
"UPDATE users SET status = 'disabled', groups_json = '[]'",
"UPDATE domains SET status = 'failed'",
"UPDATE servers SET name = '变更后服务器'",
"DELETE FROM domain_servers",
"UPDATE tasks SET message = '变更后任务'",
"DELETE FROM task_attempts",
"INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('after-backup', 'test', 'test', 'success', '不应进入恢复结果')",
} {
if _, err := db.DB.ExecContext(t.Context(), query); err != nil {
t.Fatal(err)
}
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
changed := cfg
changed.Server.Port = 19005
if err := changed.Save(fixture.path); err != nil {
t.Fatal(err)
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
t.Fatal(err)
}
for reference := range fixture.secrets {
if err := store.Put(t.Context(), reference, map[string]string{"changed": "不应保留"}); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(fixture.certificate.CertificatePath, []byte("已替换的证书"), 0o600); err != nil {
t.Fatal(err)
}
for _, round := range []string{"first_restore", "repeat_restore"} {
t.Run(round, func(t *testing.T) {
restore := newRestoreCommand()
restore.SetOut(&output)
restore.SetErr(&output)
restore.SetArgs([]string{"--input", input})
if err := restore.ExecuteContext(t.Context()); err != nil {
t.Fatal(err)
}
actual := snapshotSQLiteBusiness(t, cfg)
if !reflect.DeepEqual(actual, expected) {
for table, want := range expected {
if actual[table] != want {
t.Errorf("恢复后的表或模式不一致:%s", table)
}
}
if len(actual) != len(expected) {
t.Error("恢复后的表清单数量不一致")
}
}
assertSQLiteBusinessUsable(t, fixture)
// 同一份备份可重复恢复,读取和恢复均不能修改归档清单。
if err := validateBackupManifest(input); err != nil {
t.Fatal(err)
}
})
}
}
func assertSQLiteBusinessUsable(t *testing.T, fixture sqliteBusinessFixture) {
t.Helper()
cfg, err := config.Load(fixture.path)
if err != nil {
t.Fatal(err)
}
if cfg.Server.Port != fixture.config.Server.Port || cfg.DNS != fixture.config.DNS {
t.Fatal("服务配置或 DNS 归属未恢复")
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
t.Fatal(err)
}
for reference, want := range fixture.secrets {
actual, err := store.Get(t.Context(), reference)
if err != nil || !reflect.DeepEqual(actual, want) {
t.Fatalf("恢复后的加密凭据无法正确读取:%v", err)
}
if key := actual["private_key"]; key != "" {
if _, err := ssh.ParsePrivateKey([]byte(key)); err != nil {
t.Fatalf("恢复后的 SSH 私钥无法解析:%v", err)
}
}
}
if _, err := tls.LoadX509KeyPair(fixture.certificate.CertificatePath, fixture.certificate.PrivateKeyPath); err != nil {
t.Fatalf("恢复后的证书与私钥不可用:%v", err)
}
db, err := database.Open(t.Context(), cfg.Database)
if err != nil {
t.Fatal(err)
}
defer db.Close()
users := repository.NewSQLUserRepository(db.DB)
user, err := users.FindByIdentifier(t.Context(), "recovery-owner")
if err != nil {
t.Fatal(err)
}
if user.Status != "active" || user.Role != "super_admin" || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(fixture.password)) != nil {
t.Fatal("恢复后的用户密码或角色不可用")
}
member, err := users.GetUserByID(t.Context(), "member")
if err != nil || !reflect.DeepEqual(member.Groups, []string{"测试分组"}) {
t.Fatalf("用户分组未恢复:%v", err)
}
domains := repository.NewSQLDomainRepository(db.DB)
item, err := domains.GetByID(t.Context(), "domain")
if err != nil || item.Status != "issued" || item.ExpiresAt == nil || !item.ExpiresAt.Equal(fixture.certificate.NotAfter) {
t.Fatalf("域名和到期时间未恢复:%v", err)
}
servers := repository.NewSQLServerRepository(db.DB)
server, err := servers.GetServerByID(t.Context(), "server")
if err != nil || server.PrivateKeyRef != "secret://ssh/business" {
t.Fatalf("服务器凭据引用未恢复:%v", err)
}
tasks := repository.NewSQLTaskRepository(db.DB)
task, err := tasks.GetTask(t.Context(), "task")
if err != nil || task.DomainID != item.ID || task.OperatorID != user.ID || task.CertificateVersionID != fixture.certificate.Version {
t.Fatalf("任务资源关联未恢复:%v", err)
}
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
if err != nil || len(attempts) != 1 || attempts[0].Status != "success" {
t.Fatalf("任务尝试未恢复:%v", err)
}
var bindings int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM domain_servers WHERE domain_id = ? AND server_id = ?", item.ID, server.ID).Scan(&bindings); err != nil || bindings != 1 {
t.Fatalf("域名部署服务器关联未恢复:%v", err)
}
result, err := db.DB.ExecContext(t.Context(), "INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('after-restore', 'test', 'test', 'success', '恢复后写入')")
if err != nil {
t.Fatal(err)
}
if id, err := result.LastInsertId(); err != nil || id != 2 {
t.Fatalf("审计自增序列未恢复:编号=%d,错误=%v", id, err)
}
}