Files
sub2api/backend/internal/service/backup_service_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1323 lines
39 KiB
Go

//go:build unit
package service
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// ─── Mocks ───
type mockSettingRepo struct {
mu sync.Mutex
data map[string]string
getValueErr error
getValueCalls int
}
func newMockSettingRepo() *mockSettingRepo {
return &mockSettingRepo{data: make(map[string]string)}
}
func (m *mockSettingRepo) Get(_ context.Context, key string) (*Setting, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[key]
if !ok {
return nil, ErrSettingNotFound
}
return &Setting{Key: key, Value: v}, nil
}
func (m *mockSettingRepo) GetValue(_ context.Context, key string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.getValueCalls++
if m.getValueErr != nil {
return "", m.getValueErr
}
v, ok := m.data[key]
if !ok {
return "", nil
}
return v, nil
}
func (m *mockSettingRepo) Set(_ context.Context, key, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
return nil
}
func (m *mockSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
result := make(map[string]string)
for _, k := range keys {
if v, ok := m.data[k]; ok {
result[k] = v
}
}
return result, nil
}
func (m *mockSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
m.mu.Lock()
defer m.mu.Unlock()
for k, v := range settings {
m.data[k] = v
}
return nil
}
func (m *mockSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
result := make(map[string]string, len(m.data))
for k, v := range m.data {
result[k] = v
}
return result, nil
}
func (m *mockSettingRepo) Delete(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
return nil
}
// plainEncryptor 仅做 base64-like 包装,用于测试
type plainEncryptor struct{}
func (e *plainEncryptor) Encrypt(plaintext string) (string, error) {
return "ENC:" + plaintext, nil
}
func (e *plainEncryptor) Decrypt(ciphertext string) (string, error) {
if strings.HasPrefix(ciphertext, "ENC:") {
return strings.TrimPrefix(ciphertext, "ENC:"), nil
}
return ciphertext, fmt.Errorf("not encrypted")
}
type mockDumper struct {
dumpData []byte
dumpErr error
restored []byte
restErr error
}
func (m *mockDumper) Dump(_ context.Context) (io.ReadCloser, error) {
if m.dumpErr != nil {
return nil, m.dumpErr
}
return io.NopCloser(bytes.NewReader(m.dumpData)), nil
}
func (m *mockDumper) Restore(_ context.Context, data io.Reader) error {
if m.restErr != nil {
return m.restErr
}
d, err := io.ReadAll(data)
if err != nil {
return err
}
m.restored = d
return nil
}
// blockingDumper 可控延迟的 dumper,用于测试异步行为
type blockingDumper struct {
blockCh chan struct{}
data []byte
restErr error
}
func (d *blockingDumper) Dump(ctx context.Context) (io.ReadCloser, error) {
select {
case <-d.blockCh:
case <-ctx.Done():
return nil, ctx.Err()
}
return io.NopCloser(bytes.NewReader(d.data)), nil
}
func (d *blockingDumper) Restore(_ context.Context, data io.Reader) error {
if d.restErr != nil {
return d.restErr
}
_, _ = io.ReadAll(data)
return nil
}
type mockObjectStore struct {
objects map[string][]byte
mu sync.Mutex
failUploadFileAt int
uploadFileCalls int
deletedKeys []string
failDeleteKeys map[string]error
}
type cancelingUploadFailureStore struct {
*mockObjectStore
cancel context.CancelFunc
}
func (m *cancelingUploadFailureStore) UploadFile(_ context.Context, key string, filePath string, _ string) (int64, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, err
}
data, readErr := io.ReadAll(file)
closeErr := file.Close()
if readErr != nil {
return 0, readErr
}
if closeErr != nil {
return 0, closeErr
}
m.mu.Lock()
m.objects[key] = data
m.mu.Unlock()
m.cancel()
return 0, fmt.Errorf("injected upload failure after object landed")
}
func (m *cancelingUploadFailureStore) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
return m.mockObjectStore.Delete(ctx, key)
}
func newMockObjectStore() *mockObjectStore {
return &mockObjectStore{objects: make(map[string][]byte), failDeleteKeys: make(map[string]error)}
}
func (m *mockObjectStore) Upload(_ context.Context, key string, body io.Reader, _ string) (int64, error) {
data, err := io.ReadAll(body)
if err != nil {
return 0, err
}
m.mu.Lock()
m.objects[key] = data
m.mu.Unlock()
return int64(len(data)), nil
}
func (m *mockObjectStore) UploadFile(ctx context.Context, key string, filePath string, contentType string) (int64, error) {
m.mu.Lock()
m.uploadFileCalls++
call := m.uploadFileCalls
failAt := m.failUploadFileAt
m.mu.Unlock()
if failAt > 0 && call == failAt {
return 0, fmt.Errorf("injected upload failure at call %d", call)
}
file, err := os.Open(filePath)
if err != nil {
return 0, err
}
defer func() { _ = file.Close() }()
return m.Upload(ctx, key, file, contentType)
}
func (m *mockObjectStore) Download(_ context.Context, key string) (io.ReadCloser, error) {
m.mu.Lock()
data, ok := m.objects[key]
m.mu.Unlock()
if !ok {
return nil, fmt.Errorf("not found: %s", key)
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (m *mockObjectStore) Delete(_ context.Context, key string) error {
m.mu.Lock()
m.deletedKeys = append(m.deletedKeys, key)
if err, ok := m.failDeleteKeys[key]; ok {
m.mu.Unlock()
return err
}
delete(m.objects, key)
m.mu.Unlock()
return nil
}
func (m *mockObjectStore) PresignURL(_ context.Context, key string, _ time.Duration) (string, error) {
return "https://presigned.example.com/" + key, nil
}
func (m *mockObjectStore) HeadBucket(_ context.Context) error {
return nil
}
func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObjectStore) *BackupService {
cfg := &config.Config{
Database: config.DatabaseConfig{
Host: "localhost",
Port: 5432,
User: "test",
DBName: "testdb",
},
// A fixed encryption key is the supported production posture: persisting
// an S3 secret requires it (#4524).
Totp: config.TotpConfig{EncryptionKeyConfigured: true},
}
factory := func(_ context.Context, _ *BackupS3Config) (BackupObjectStore, error) {
return store, nil
}
return NewBackupService(repo, cfg, &plainEncryptor{}, factory, dumper)
}
// newTestBackupServiceEphemeralKey mirrors a deployment that never set
// TOTP_ENCRYPTION_KEY, so the secret encryption key is auto-generated.
func newTestBackupServiceEphemeralKey(repo *mockSettingRepo) *BackupService {
cfg := &config.Config{
Database: config.DatabaseConfig{Host: "localhost", Port: 5432, User: "test", DBName: "testdb"},
Totp: config.TotpConfig{EncryptionKeyConfigured: false},
}
factory := func(_ context.Context, _ *BackupS3Config) (BackupObjectStore, error) {
return newMockObjectStore(), nil
}
return NewBackupService(repo, cfg, &plainEncryptor{}, factory, &mockDumper{})
}
func seedS3Config(t *testing.T, repo *mockSettingRepo) {
t.Helper()
cfg := BackupS3Config{
Bucket: "test-bucket",
AccessKeyID: "AKID",
SecretAccessKey: "ENC:secret123",
Prefix: "backups",
}
data, _ := json.Marshal(cfg)
require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data)))
}
// ─── Tests ───
func TestBackupService_S3ConfigEncryption(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
// 保存配置 -> SecretAccessKey 应被加密
_, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
Bucket: "my-bucket",
AccessKeyID: "AKID",
SecretAccessKey: "my-secret",
Prefix: "backups",
})
require.NoError(t, err)
// 直接读取数据库中存储的值,应该是加密后的
raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config)
var stored BackupS3Config
require.NoError(t, json.Unmarshal([]byte(raw), &stored))
require.Equal(t, "ENC:my-secret", stored.SecretAccessKey)
// 通过 GetS3Config 获取应该脱敏
cfg, err := svc.GetS3Config(context.Background())
require.NoError(t, err)
require.Empty(t, cfg.SecretAccessKey)
require.Equal(t, "my-bucket", cfg.Bucket)
// loadS3Config 内部应解密
internal, err := svc.loadS3Config(context.Background())
require.NoError(t, err)
require.Equal(t, "my-secret", internal.SecretAccessKey)
}
func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
// 先保存一个有 secret 的配置
_, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
Bucket: "my-bucket",
AccessKeyID: "AKID",
SecretAccessKey: "original-secret",
})
require.NoError(t, err)
// 再更新时不提供 secret,应保留原值
_, err = svc.UpdateS3Config(context.Background(), BackupS3Config{
Bucket: "my-bucket",
AccessKeyID: "AKID-NEW",
})
require.NoError(t, err)
internal, err := svc.loadS3Config(context.Background())
require.NoError(t, err)
require.Equal(t, "original-secret", internal.SecretAccessKey)
require.Equal(t, "AKID-NEW", internal.AccessKeyID)
}
func TestBackupService_UpdateS3Config_RejectsEphemeralKey(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupServiceEphemeralKey(repo)
// 提供新 secret 但密钥为自动生成 -> 必须拒绝,避免重启后无法解密(#4524)。
_, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
Bucket: "my-bucket",
AccessKeyID: "AKID",
SecretAccessKey: "my-secret",
Prefix: "backups",
})
require.ErrorIs(t, err, ErrSecretEncryptionKeyNotConfigured)
// 不应写入任何配置。
raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config)
require.Empty(t, raw)
}
func TestBackupService_UpdateS3Config_NoSecretAllowedWithEphemeralKey(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupServiceEphemeralKey(repo)
// 不含 secret 的更新(如只改 bucket)不触碰加密路径,应放行。
_, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
Bucket: "my-bucket",
AccessKeyID: "AKID",
})
require.NoError(t, err)
}
func TestBackupService_EncryptionKeyConfigured(t *testing.T) {
repo := newMockSettingRepo()
require.True(t, newTestBackupService(repo, &mockDumper{}, newMockObjectStore()).EncryptionKeyConfigured())
require.False(t, newTestBackupServiceEphemeralKey(repo).EncryptionKeyConfigured())
}
func TestBackupService_SaveRecordConcurrency(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
var wg sync.WaitGroup
n := 20
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
record := &BackupRecord{
ID: fmt.Sprintf("rec-%d", idx),
Status: "completed",
StartedAt: time.Now().Format(time.RFC3339),
}
_ = svc.saveRecord(context.Background(), record)
}(i)
}
wg.Wait()
records, err := svc.loadRecords(context.Background())
require.NoError(t, err)
require.Len(t, records, n)
}
func TestBackupService_LoadRecords_Empty(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
records, err := svc.loadRecords(context.Background())
require.NoError(t, err)
require.Nil(t, records) // 无数据时返回 nil
}
func TestBackupService_LoadRecords_Corrupted(t *testing.T) {
repo := newMockSettingRepo()
_ = repo.Set(context.Background(), settingKeyBackupRecords, "not valid json{{{")
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
records, err := svc.loadRecords(context.Background())
require.Error(t, err) // 损坏数据应返回错误
require.Nil(t, records)
}
func TestBackupService_CreateBackup_Streaming(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
dumper := &mockDumper{dumpData: []byte(dumpContent)}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
require.Equal(t, "completed", record.Status)
require.Greater(t, record.SizeBytes, int64(0))
require.NotEmpty(t, record.S3Key)
// 验证 S3 上确实有文件
store.mu.Lock()
require.Len(t, store.objects, 1)
store.mu.Unlock()
}
func TestBackupService_CreateBackup_SplitsCompressedArchive(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := entropyBackupFixture(512)
dumper := &mockDumper{dumpData: dumpContent}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
svc.partSizeBytes = 32
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
require.Equal(t, "completed", record.Status)
require.Greater(t, len(record.Parts), 1)
require.Empty(t, record.S3Key)
var compressed bytes.Buffer
store.mu.Lock()
for _, part := range record.Parts {
data, ok := store.objects[part.S3Key]
require.True(t, ok)
require.LessOrEqual(t, len(data), 32)
compressed.Write(data)
}
store.mu.Unlock()
gzReader, err := gzip.NewReader(bytes.NewReader(compressed.Bytes()))
require.NoError(t, err)
decompressed, err := io.ReadAll(gzReader)
require.NoError(t, err)
require.NoError(t, gzReader.Close())
require.Equal(t, dumpContent, decompressed)
}
func TestBackupService_StartBackup_SplitsCompressedArchive(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store)
svc.partSizeBytes = 32
record, err := svc.StartBackup(context.Background(), "manual", 14)
require.NoError(t, err)
svc.wg.Wait()
final, err := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "completed", final.Status)
require.Greater(t, len(final.Parts), 1)
require.Empty(t, final.S3Key)
}
func TestBackupService_StartBackup_UploadFailureCleansUploadedParts(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
store.failUploadFileAt = 2
svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store)
svc.partSizeBytes = 32
record, err := svc.StartBackup(context.Background(), "manual", 14)
require.NoError(t, err)
svc.wg.Wait()
final, err := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "failed", final.Status)
require.NotEmpty(t, final.Parts)
store.mu.Lock()
deletedKeys := append([]string(nil), store.deletedKeys...)
store.mu.Unlock()
for _, part := range final.Parts {
require.Contains(t, deletedKeys, part.S3Key)
}
}
func TestBackupService_UploadFailureCleanupUsesDetachedContext(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
svc.partSizeBytes = 4
archive, err := os.CreateTemp("", "backup-upload-context-*.gz")
require.NoError(t, err)
archivePath := archive.Name()
defer func() { _ = os.Remove(archivePath) }()
_, err = archive.Write([]byte("0123456789"))
require.NoError(t, err)
require.NoError(t, archive.Close())
ctx, cancel := context.WithCancel(context.Background())
store := &cancelingUploadFailureStore{
mockObjectStore: newMockObjectStore(),
cancel: cancel,
}
record := &BackupRecord{ID: "cancel-cleanup", S3Key: "backups/cancel-cleanup.sql.gz"}
err = svc.uploadBackupArchive(ctx, record, store, &BackupS3Config{Prefix: "backups"}, archivePath)
require.Error(t, err)
require.NotContains(t, err.Error(), "context canceled")
store.mu.Lock()
defer store.mu.Unlock()
for _, part := range record.Parts {
require.Contains(t, store.deletedKeys, part.S3Key)
require.NotContains(t, store.objects, part.S3Key)
}
}
func entropyBackupFixture(size int) []byte {
data := make([]byte, size)
for i := range data {
data[i] = byte((i*31 + 17) % 251)
}
return data
}
func TestBackupService_CreateBackup_DumpFailure(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumper := &mockDumper{dumpErr: fmt.Errorf("pg_dump failed")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.Error(t, err)
require.Equal(t, "failed", record.Status)
require.Contains(t, record.ErrorMsg, "pg_dump")
}
func TestBackupService_CreateBackup_NoS3Config(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
_, err := svc.CreateBackup(context.Background(), "manual", 14)
require.ErrorIs(t, err, ErrBackupS3NotConfigured)
}
func TestBackupService_CreateBackup_ConcurrentBlocked(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
// 使用一个慢速 dumper 来模拟正在进行的备份
dumper := &mockDumper{dumpData: []byte("data")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
// 手动设置 backingUp 标志
svc.opMu.Lock()
svc.backingUp = true
svc.opMu.Unlock()
_, err := svc.CreateBackup(context.Background(), "manual", 14)
require.ErrorIs(t, err, ErrBackupInProgress)
}
// TestBackupService_RunScheduledBackup_LeaderElection verifies the scheduled
// backup is gated by a cross-instance leader lock: a non-leader instance skips
// the dump entirely so a clustered deployment does not run N identical backups
// against the same database, while the leader runs it and releases the lock
// afterward. Manual backups (CreateBackup/StartBackup) are intentionally left
// ungated and are covered by the other tests.
func TestBackupService_RunScheduledBackup_LeaderElection(t *testing.T) {
t.Run("non-leader skips", func(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, store)
// A peer already owns the lock, so this instance is not the leader.
cache := &fakeLeaderLockCache{}
peerRelease, ok := tryAcquireSingletonLeaderLock(context.Background(), cache, nil, backupScheduledLeaderLockKey, "peer", time.Minute)
require.True(t, ok)
defer peerRelease()
svc.SetLeaderLock(cache, nil)
svc.runScheduledBackup()
store.mu.Lock()
require.Empty(t, store.objects, "non-leader must not upload a backup")
store.mu.Unlock()
records, err := svc.ListBackups(context.Background())
require.NoError(t, err)
require.Empty(t, records, "non-leader must not create a backup record")
require.Equal(t, "peer", cache.heldBy(backupScheduledLeaderLockKey), "peer keeps the lock")
})
t.Run("leader runs and releases", func(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("-- dump\n")}, store)
cache := &fakeLeaderLockCache{}
svc.SetLeaderLock(cache, nil)
svc.runScheduledBackup()
records, err := svc.ListBackups(context.Background())
require.NoError(t, err)
require.Len(t, records, 1, "leader creates exactly one backup record")
require.Equal(t, "completed", records[0].Status)
require.Empty(t, cache.heldBy(backupScheduledLeaderLockKey), "leader releases the lock when done")
})
}
func TestBackupService_RestoreBackup_Streaming(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
dumper := &mockDumper{dumpData: []byte(dumpContent)}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
// 先创建一个备份
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
// 恢复
err = svc.RestoreBackup(context.Background(), record.ID)
require.NoError(t, err)
// 验证 psql 收到的数据是否与原始 dump 内容一致
require.Equal(t, dumpContent, string(dumper.restored))
}
func TestBackupService_RestoreBackup_SplitParts(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := entropyBackupFixture(512)
dumper := &mockDumper{}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
compressed := gzipBackupBytes(t, dumpContent)
parts := splitBackupBytes(compressed, 11)
recordParts := make([]BackupPart, 0, len(parts))
for i, data := range parts {
key := fmt.Sprintf("backups/split-1/payload.part-%06d", i+1)
store.objects[key] = data
recordParts = append(recordParts, BackupPart{
Index: i + 1,
S3Key: key,
SizeBytes: int64(len(data)),
SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
})
}
record := &BackupRecord{
ID: "split-1",
Status: "completed",
Parts: recordParts,
SizeBytes: int64(len(compressed)),
}
require.NoError(t, svc.saveRecord(context.Background(), record))
require.NoError(t, svc.RestoreBackup(context.Background(), record.ID))
require.Equal(t, dumpContent, dumper.restored)
}
func TestBackupService_RestoreBackup_SplitPartsMissingPartDoesNotRestore(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := entropyBackupFixture(256)
dumper := &mockDumper{}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
compressed := gzipBackupBytes(t, dumpContent)
parts := splitBackupBytes(compressed, 11)
recordParts := make([]BackupPart, 0, len(parts))
for i, data := range parts {
key := fmt.Sprintf("backups/split-missing/payload.part-%06d", i+1)
store.objects[key] = data
recordParts = append(recordParts, BackupPart{
Index: i + 1,
S3Key: key,
SizeBytes: int64(len(data)),
SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
})
}
delete(store.objects, recordParts[1].S3Key)
record := &BackupRecord{ID: "split-missing", Status: "completed", Parts: recordParts}
require.NoError(t, svc.saveRecord(context.Background(), record))
require.Error(t, svc.RestoreBackup(context.Background(), record.ID))
require.Empty(t, dumper.restored)
}
func TestBackupService_DownloadBackupPartsRejectsMismatchedMetadata(t *testing.T) {
tests := []struct {
name string
part BackupPart
want string
}{
{
name: "size",
part: BackupPart{Index: 1, S3Key: "backups/mismatch/size", SizeBytes: 4},
want: "size mismatch",
},
{
name: "checksum",
part: BackupPart{Index: 1, S3Key: "backups/mismatch/checksum", SizeBytes: 3, SHA256: "bad-checksum"},
want: "checksum mismatch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
store.objects[tt.part.S3Key] = []byte("abc")
svc := newTestBackupService(repo, &mockDumper{}, store)
_, err := svc.downloadBackupParts(context.Background(), store, []BackupPart{tt.part})
require.ErrorContains(t, err, tt.want)
})
}
}
func gzipBackupBytes(t *testing.T, content []byte) []byte {
t.Helper()
var out bytes.Buffer
writer := gzip.NewWriter(&out)
_, err := writer.Write(content)
require.NoError(t, err)
require.NoError(t, writer.Close())
return out.Bytes()
}
func splitBackupBytes(data []byte, partSize int) [][]byte {
parts := make([][]byte, 0, (len(data)+partSize-1)/partSize)
for len(data) > 0 {
size := partSize
if len(data) < size {
size = len(data)
}
parts = append(parts, append([]byte(nil), data[:size]...))
data = data[size:]
}
return parts
}
func TestBackupService_RestoreBackup_NotCompleted(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
// 手动插入一条 failed 记录
_ = svc.saveRecord(context.Background(), &BackupRecord{
ID: "fail-1",
Status: "failed",
})
err := svc.RestoreBackup(context.Background(), "fail-1")
require.Error(t, err)
}
func TestBackupService_DeleteBackup(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := "data"
dumper := &mockDumper{dumpData: []byte(dumpContent)}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
// S3 中应有文件
store.mu.Lock()
require.Len(t, store.objects, 1)
store.mu.Unlock()
// 删除
err = svc.DeleteBackup(context.Background(), record.ID)
require.NoError(t, err)
// S3 中文件应被删除
store.mu.Lock()
require.Len(t, store.objects, 0)
store.mu.Unlock()
// 记录应不存在
_, err = svc.GetBackupRecord(context.Background(), record.ID)
require.ErrorIs(t, err, ErrBackupNotFound)
}
func TestBackupService_DeleteBackup_RunningKeepsUploadObjects(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
parts := []BackupPart{
{Index: 1, S3Key: "backups/running/payload.part-000001", SizeBytes: 3},
{Index: 2, S3Key: "backups/running/payload.part-000002", SizeBytes: 3},
}
for _, part := range parts {
store.objects[part.S3Key] = []byte("abc")
}
record := &BackupRecord{ID: "running-delete", Status: "running", Parts: parts}
require.NoError(t, svc.saveRecord(context.Background(), record))
err := svc.DeleteBackup(context.Background(), record.ID)
require.ErrorIs(t, err, ErrBackupInProgress)
store.mu.Lock()
require.Empty(t, store.deletedKeys)
for _, part := range parts {
require.Contains(t, store.objects, part.S3Key)
}
store.mu.Unlock()
got, getErr := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, getErr)
require.Equal(t, "running", got.Status)
}
func TestBackupService_GetDownloadURL(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumper := &mockDumper{dumpData: []byte("data")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
download, err := svc.GetBackupDownloadURL(context.Background(), record.ID)
require.NoError(t, err)
require.Contains(t, download.URL, "https://presigned.example.com/")
}
func TestBackupService_DeleteBackup_SplitPartsFailureKeepsRecord(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
parts := []BackupPart{
{Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 3},
{Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 3},
{Index: 3, S3Key: "backups/split/payload.part-000003", SizeBytes: 3},
}
for _, part := range parts {
store.objects[part.S3Key] = []byte("abc")
}
store.failDeleteKeys[parts[1].S3Key] = fmt.Errorf("delete failed")
record := &BackupRecord{ID: "split-delete", Status: "completed", Parts: parts}
require.NoError(t, svc.saveRecord(context.Background(), record))
err := svc.DeleteBackup(context.Background(), record.ID)
require.Error(t, err)
store.mu.Lock()
deleted := append([]string(nil), store.deletedKeys...)
store.mu.Unlock()
for _, part := range parts {
require.Contains(t, deleted, part.S3Key)
}
got, getErr := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, getErr)
require.Equal(t, record.ID, got.ID)
store.mu.Lock()
require.Contains(t, store.objects, parts[1].S3Key)
store.mu.Unlock()
}
func TestBackupService_GetDownloadURL_SplitParts(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
parts := []BackupPart{
{Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 7},
{Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 5},
}
record := &BackupRecord{ID: "split-download", Status: "completed", Parts: parts}
require.NoError(t, svc.saveRecord(context.Background(), record))
download, err := svc.GetBackupDownloadURL(context.Background(), record.ID)
require.NoError(t, err)
require.Empty(t, download.URL)
require.Len(t, download.Parts, 2)
require.Equal(t, 1, download.Parts[0].Index)
require.Equal(t, int64(5), download.Parts[0].SizeBytes)
require.Equal(t, "https://presigned.example.com/backups/split/payload.part-000001", download.Parts[0].URL)
require.Equal(t, 2, download.Parts[1].Index)
}
func TestBackupService_CleanupOldBackups_SplitParts(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
now := time.Now()
parts := []BackupPart{
{Index: 1, S3Key: "backups/old/payload.part-000001", SizeBytes: 3},
{Index: 2, S3Key: "backups/old/payload.part-000002", SizeBytes: 3},
}
for _, part := range parts {
store.objects[part.S3Key] = []byte("abc")
}
require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
ID: "new",
Status: "completed",
StartedAt: now.Format(time.RFC3339),
}))
require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
ID: "old",
Status: "completed",
StartedAt: now.Add(-2 * time.Hour).Format(time.RFC3339),
Parts: parts,
}))
err := svc.cleanupOldBackups(context.Background(), &BackupScheduleConfig{RetainCount: 1})
require.NoError(t, err)
_, err = svc.GetBackupRecord(context.Background(), "old")
require.ErrorIs(t, err, ErrBackupNotFound)
store.mu.Lock()
for _, part := range parts {
require.NotContains(t, store.objects, part.S3Key)
}
store.mu.Unlock()
}
func TestBackupService_ListBackups_Sorted(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
now := time.Now()
for i := 0; i < 3; i++ {
_ = svc.saveRecord(context.Background(), &BackupRecord{
ID: fmt.Sprintf("rec-%d", i),
Status: "completed",
StartedAt: now.Add(time.Duration(i) * time.Hour).Format(time.RFC3339),
})
}
records, err := svc.ListBackups(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
// 最新在前
require.Equal(t, "rec-2", records[0].ID)
require.Equal(t, "rec-0", records[2].ID)
}
func TestBackupService_TestS3Connection(t *testing.T) {
repo := newMockSettingRepo()
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
err := svc.TestS3Connection(context.Background(), BackupS3Config{
Bucket: "test",
AccessKeyID: "ak",
SecretAccessKey: "sk",
})
require.NoError(t, err)
}
func TestBackupService_TestS3Connection_Incomplete(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
err := svc.TestS3Connection(context.Background(), BackupS3Config{
Bucket: "test",
})
require.Error(t, err)
require.Contains(t, err.Error(), "incomplete")
}
func TestBackupService_Schedule_CronValidation(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
svc.cronSched = nil // 未初始化 cron
// 启用但 cron 为空
_, err := svc.UpdateSchedule(context.Background(), BackupScheduleConfig{
Enabled: true,
CronExpr: "",
})
require.Error(t, err)
// 无效的 cron 表达式
_, err = svc.UpdateSchedule(context.Background(), BackupScheduleConfig{
Enabled: true,
CronExpr: "invalid",
})
require.Error(t, err)
}
func TestBackupService_LoadS3Config_Corrupted(t *testing.T) {
repo := newMockSettingRepo()
_ = repo.Set(context.Background(), settingKeyBackupS3Config, "not json!!!!")
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
cfg, err := svc.loadS3Config(context.Background())
require.Error(t, err)
require.Nil(t, cfg)
}
// ─── Async Backup Tests ───
func TestStartBackup_ReturnsImmediately(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
record, err := svc.StartBackup(context.Background(), "manual", 14)
require.NoError(t, err)
require.Equal(t, "running", record.Status)
require.NotEmpty(t, record.ID)
// 释放 dumper 让后台完成
close(dumper.blockCh)
svc.wg.Wait()
// 验证最终状态
final, err := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "completed", final.Status)
require.Greater(t, final.SizeBytes, int64(0))
}
func TestStartBackup_ConcurrentBlocked(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
// 第一次启动
_, err := svc.StartBackup(context.Background(), "manual", 14)
require.NoError(t, err)
// 第二次应被阻塞
_, err = svc.StartBackup(context.Background(), "manual", 14)
require.ErrorIs(t, err, ErrBackupInProgress)
close(dumper.blockCh)
svc.wg.Wait()
}
func TestStartBackup_ShuttingDown(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, newMockObjectStore())
svc.shuttingDown.Store(true)
_, err := svc.StartBackup(context.Background(), "manual", 14)
require.Error(t, err)
require.Contains(t, err.Error(), "shutting down")
}
func TestRecoverStaleRecords(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
// 模拟一条孤立的 running 记录
_ = svc.saveRecord(context.Background(), &BackupRecord{
ID: "stale-1",
Status: "running",
StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
})
// 模拟一条孤立的恢复中记录
_ = svc.saveRecord(context.Background(), &BackupRecord{
ID: "stale-2",
Status: "completed",
RestoreStatus: "running",
StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
})
svc.recoverStaleRecords()
r1, _ := svc.GetBackupRecord(context.Background(), "stale-1")
require.Equal(t, "failed", r1.Status)
require.Contains(t, r1.ErrorMsg, "server restart")
r2, _ := svc.GetBackupRecord(context.Background(), "stale-2")
require.Equal(t, "failed", r2.RestoreStatus)
require.Contains(t, r2.RestoreError, "server restart")
}
func TestBackupService_RecoverStaleRecords_CleansBackupObjects(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
parts := []BackupPart{
{Index: 1, S3Key: "backups/stale/payload.part-000001", SizeBytes: 3},
{Index: 2, S3Key: "backups/stale/payload.part-000002", SizeBytes: 3},
}
for _, part := range parts {
store.objects[part.S3Key] = []byte("abc")
}
require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
ID: "stale-parts",
Status: "running",
Parts: parts,
StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
}))
svc.recoverStaleRecords()
record, err := svc.GetBackupRecord(context.Background(), "stale-parts")
require.NoError(t, err)
require.Equal(t, "failed", record.Status)
store.mu.Lock()
defer store.mu.Unlock()
for _, part := range parts {
require.Contains(t, store.deletedKeys, part.S3Key)
require.NotContains(t, store.objects, part.S3Key)
}
}
func TestBackupService_RecoverStaleRecords_PreservesKeysWhenCleanupFails(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
store := newMockObjectStore()
svc := newTestBackupService(repo, &mockDumper{}, store)
part := BackupPart{Index: 1, S3Key: "backups/stale-failed/payload.part-000001", SizeBytes: 3}
store.objects[part.S3Key] = []byte("abc")
store.failDeleteKeys[part.S3Key] = fmt.Errorf("delete failed")
require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
ID: "stale-cleanup-failed",
Status: "running",
Parts: []BackupPart{part},
StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
}))
svc.recoverStaleRecords()
record, err := svc.GetBackupRecord(context.Background(), "stale-cleanup-failed")
require.NoError(t, err)
require.Equal(t, "failed", record.Status)
require.Contains(t, record.ErrorMsg, "cleanup failed")
require.Equal(t, part.S3Key, record.Parts[0].S3Key)
store.mu.Lock()
defer store.mu.Unlock()
require.Contains(t, store.objects, part.S3Key)
}
func TestGracefulShutdown(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
_, err := svc.StartBackup(context.Background(), "manual", 14)
require.NoError(t, err)
// Stop 应该等待备份完成
done := make(chan struct{})
go func() {
svc.Stop()
close(done)
}()
// 短暂等待确认 Stop 还在等待
select {
case <-done:
t.Fatal("Stop returned before backup finished")
case <-time.After(100 * time.Millisecond):
// 预期:Stop 还在等待
}
// 释放备份
close(dumper.blockCh)
// 现在 Stop 应该完成
select {
case <-done:
// 预期
case <-time.After(5 * time.Second):
t.Fatal("Stop did not return after backup finished")
}
}
func TestStartRestore_Async(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
dumper := &mockDumper{dumpData: []byte(dumpContent)}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
// 先创建一个备份(同步方式)
record, err := svc.CreateBackup(context.Background(), "manual", 14)
require.NoError(t, err)
// 异步恢复
restored, err := svc.StartRestore(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "running", restored.RestoreStatus)
svc.wg.Wait()
// 验证最终状态
final, err := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "completed", final.RestoreStatus)
}
func TestBackupService_StartRestore_SplitParts(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
dumpContent := entropyBackupFixture(384)
dumper := &mockDumper{}
store := newMockObjectStore()
svc := newTestBackupService(repo, dumper, store)
compressed := gzipBackupBytes(t, dumpContent)
parts := splitBackupBytes(compressed, 13)
recordParts := make([]BackupPart, 0, len(parts))
for i, data := range parts {
key := fmt.Sprintf("backups/split-async/payload.part-%06d", i+1)
store.objects[key] = data
recordParts = append(recordParts, BackupPart{
Index: i + 1,
S3Key: key,
SizeBytes: int64(len(data)),
SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
})
}
record := &BackupRecord{ID: "split-async", Status: "completed", Parts: recordParts}
require.NoError(t, svc.saveRecord(context.Background(), record))
started, err := svc.StartRestore(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "running", started.RestoreStatus)
svc.wg.Wait()
final, err := svc.GetBackupRecord(context.Background(), record.ID)
require.NoError(t, err)
require.Equal(t, "completed", final.RestoreStatus)
require.Equal(t, dumpContent, dumper.restored)
}