Scert/backend/internal/cli/database_maintenance_test.go

159 lines
5.6 KiB
Go

package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
)
func TestUnsupportedBackupDriverFailsBeforeFilesystemChanges(t *testing.T) {
for _, driver := range []string{"unknown", ""} {
t.Run(driver, func(t *testing.T) {
root := t.TempDir()
cfg := config.Config{Database: config.DatabaseConfig{Driver: driver}}
output := filepath.Join(root, "not-created", "backup")
err := createBackup(t.Context(), cfg, filepath.Join(root, "scert.json"), output)
if err == nil || !strings.Contains(err.Error(), "备份") {
t.Fatal("不支持的数据库不能报告备份成功")
}
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
t.Fatal("拒绝备份前不应创建输出目录")
}
})
}
}
func TestPostgresRestoreWithoutSecretsFailsBeforeReplacingExistingConfig(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "input")
if err := os.Mkdir(input, 0o700); err != nil {
t.Fatal(err)
}
cfg := config.Default(root)
// 显式覆盖路径,避免 Linux 测试使用正式安装目录。
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/restore-test",
MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30,
}
if err := cfg.Save(filepath.Join(input, "scert.json")); err != nil {
t.Fatal(err)
}
if err := writeBackupManifest(input); err != nil {
t.Fatal(err)
}
currentPath := filepath.Join(root, "current.json")
const original = "preserve-current-config"
if err := os.WriteFile(currentPath, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
previous := configPath
configPath = currentPath
t.Cleanup(func() { configPath = previous })
command := newRestoreCommand()
var output strings.Builder
command.SetOut(&output)
command.SetErr(&output)
command.SetArgs([]string{"--input", input})
if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "master.key") {
t.Fatal("缺少数据库凭据的 PostgreSQL 恢复没有明确拒绝")
}
content, err := os.ReadFile(currentPath)
if err != nil || string(content) != original {
t.Fatal("拒绝恢复时改变了现有配置")
}
if _, err := os.Stat(cfg.Paths.Data); !os.IsNotExist(err) {
t.Fatal("拒绝恢复前不应创建锁目录或数据库")
}
}
func TestPostgresBackupDriverSupported(t *testing.T) {
if err := validateBackupDriver("postgres"); err != nil {
t.Fatalf("PostgreSQL 应支持完整备份恢复:%v", err)
}
}
func TestDatabaseRollbackSurvivesCanceledRequestWithDeadline(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
called := false
err := runDatabaseRollback(ctx, func(rollbackCtx context.Context) error {
called = true
if rollbackCtx.Err() != nil {
t.Fatal("原请求取消不应立即取消数据库回滚")
}
deadline, ok := rollbackCtx.Deadline()
if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 5*time.Minute {
t.Fatal("回滚必须使用独立且有界的超时")
}
return nil
})
if err != nil || !called {
t.Fatal("取消后仍应执行回滚")
}
}
func TestMaintenanceResolvesEncryptedMySQLCredentialsOnlyInMemory(t *testing.T) {
directory := t.TempDir()
store, err := secretstore.Open(directory)
if err != nil {
t.Fatal(err)
}
cfg := config.DatabaseConfig{
Driver: "mysql", DSN: "tcp(db.example.test:3306)/scert?tls=true",
CredentialRef: "secret://database/maintenance-test",
}
const password = "maintenance-test-password"
if err := store.Put(t.Context(), cfg.CredentialRef, map[string]string{"username": "backup-user", "password": password}); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(filepath.Join(directory, "secrets.json"))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveMaintenanceDatabase(t.Context(), cfg, directory)
if err != nil {
t.Fatal(err)
}
parsed, err := mysql.ParseDSN(resolved.DSN)
if err != nil || parsed.User != "backup-user" || parsed.Passwd != password || parsed.TLSConfig != "true" {
t.Fatal("维护连接没有正确补全加密凭据或保留 TLS 设置")
}
if strings.Contains(cfg.DSN, "backup-user") || strings.Contains(cfg.DSN, password) {
t.Fatal("原始配置被写入数据库账号密码")
}
after, err := os.ReadFile(filepath.Join(directory, "secrets.json"))
if err != nil || !bytes.Equal(before, after) || bytes.Contains(after, []byte(password)) {
t.Fatal("维护连接改变了敏感文件或泄露明文密码")
}
}
func TestBackupValidatesDatabaseCredentialReference(t *testing.T) {
directory := t.TempDir()
if err := createTestSecretStore(directory); err != nil {
t.Fatal(err)
}
cfg := config.Config{Database: config.DatabaseConfig{CredentialRef: "secret://database/missing"}}
if err := validateBackupSecretStore(t.Context(), directory, cfg); err == nil {
t.Fatal("备份不能忽略缺失的数据库加密凭据")
}
if _, err := resolveMaintenanceDatabase(t.Context(), config.DatabaseConfig{
Driver: "mysql", DSN: "tcp(db.example.test:3306)/scert?tls=true", CredentialRef: cfg.Database.CredentialRef,
}, directory); err == nil || strings.Contains(err.Error(), cfg.Database.CredentialRef) {
t.Fatal("维护连接缺失凭据时必须失败且不泄露完整引用")
}
}