diff --git a/server/internal/service/verification_service_test.go b/server/internal/service/verification_service_test.go new file mode 100644 index 0000000..eb8a619 --- /dev/null +++ b/server/internal/service/verification_service_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "backupx/server/internal/backup" + "backupx/server/internal/config" + "backupx/server/internal/database" + "backupx/server/internal/logger" + "backupx/server/internal/model" + "backupx/server/internal/repository" + "backupx/server/internal/storage" + "backupx/server/internal/storage/codec" + storageRclone "backupx/server/internal/storage/rclone" +) + +type verifyTestHarness struct { + verify *VerificationService + execution *BackupExecutionService + records repository.BackupRecordRepository + storageDir string +} + +func newVerifyTestHarness(t *testing.T) *verifyTestHarness { + t.Helper() + baseDir := t.TempDir() + sourceDir := filepath.Join(baseDir, "source") + storageDir := filepath.Join(baseDir, "storage") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello-verify"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + log, err := logger.New(config.LogConfig{Level: "error"}) + if err != nil { + t.Fatalf("logger.New: %v", err) + } + db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log) + if err != nil { + t.Fatalf("database.Open: %v", err) + } + cipher := codec.NewConfigCipher("verify-secret") + targets := repository.NewStorageTargetRepository(db) + tasks := repository.NewBackupTaskRepository(db) + records := repository.NewBackupRecordRepository(db) + verifications := repository.NewVerificationRecordRepository(db) + nodes := repository.NewNodeRepository(db) + targetCipher, err := cipher.EncryptJSON(map[string]any{"basePath": storageDir}) + if err != nil { + t.Fatalf("EncryptJSON: %v", err) + } + if err := targets.Create(context.Background(), &model.StorageTarget{Name: "local", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: targetCipher, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil { + t.Fatalf("create target: %v", err) + } + task := &model.BackupTask{Name: "verify-test", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"} + if err := tasks.Create(context.Background(), task); err != nil { + t.Fatalf("create task: %v", err) + } + + logHub := backup.NewLogHub() + runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil)) + storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory()) + execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "") + verify := NewVerificationService(verifications, records, tasks, targets, nodes, storageRegistry, backup.NewLogHub(), cipher, baseDir, 2) + + return &verifyTestHarness{verify: verify, execution: execution, records: records, storageDir: storageDir} +} + +// runVerify 同步执行一次验证并返回终态记录。 +func (h *verifyTestHarness) runVerify(t *testing.T, backupRecordID uint) *VerificationRecordDetail { + t.Helper() + ctx := context.Background() + done := make(chan struct{}) + h.verify.async = func(job func()) { + go func() { job(); close(done) }() + } + detail, err := h.verify.Start(ctx, backupRecordID, "quick", "tester") + if err != nil { + t.Fatalf("verify Start: %v", err) + } + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("verify did not complete in time") + } + final, err := h.verify.Get(ctx, detail.ID) + if err != nil { + t.Fatalf("verify Get: %v", err) + } + return final +} + +// TestVerificationService_Success 覆盖正常路径:对一个有效(gzip 压缩)的备份做验证应通过。 +// 同时回归保护 #77——新增的 SHA-256 校验不得误伤合法的压缩备份。 +func TestVerificationService_Success(t *testing.T) { + h := newVerifyTestHarness(t) + backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + if backupDetail.Status != "success" { + t.Fatalf("expected backup success, got %s", backupDetail.Status) + } + + final := h.runVerify(t, backupDetail.ID) + if final.Status != model.VerificationRecordStatusSuccess { + t.Fatalf("expected verify success, got %s (err=%s)", final.Status, final.ErrorMessage) + } +} + +// TestVerificationService_RejectsCorruptedBackup 验证 #77 的完整性校验: +// 存储对象被损坏时验证必须失败并给出 checksum 失败信息。 +func TestVerificationService_RejectsCorruptedBackup(t *testing.T) { + h := newVerifyTestHarness(t) + backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + if backupDetail.Status != "success" { + t.Fatalf("expected backup success, got %s", backupDetail.Status) + } + + // 破坏已存储的备份对象,使其 SHA-256 与记录不符。 + corrupted := false + if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error { + if walkErr != nil || info.IsDir() { + return walkErr + } + f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644) + if openErr != nil { + return openErr + } + defer f.Close() + if _, writeErr := f.WriteString("corrupt"); writeErr != nil { + return writeErr + } + corrupted = true + return nil + }); walkErr != nil { + t.Fatalf("corrupt walk: %v", walkErr) + } + if !corrupted { + t.Fatal("did not find a stored backup object to corrupt") + } + + final := h.runVerify(t, backupDetail.ID) + if final.Status != model.VerificationRecordStatusFailed { + t.Fatalf("expected verify to FAIL on corrupted backup, got %s", final.Status) + } + if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") { + t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage) + } +}