backup_service_test.go 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "compress/gzip"
  6. "context"
  7. "crypto/sha256"
  8. "encoding/json"
  9. "fmt"
  10. "io"
  11. "os"
  12. "strings"
  13. "sync"
  14. "testing"
  15. "time"
  16. "github.com/stretchr/testify/require"
  17. "github.com/Wei-Shaw/sub2api/internal/config"
  18. )
  19. // ─── Mocks ───
  20. type mockSettingRepo struct {
  21. mu sync.Mutex
  22. data map[string]string
  23. getValueErr error
  24. getValueCalls int
  25. }
  26. func newMockSettingRepo() *mockSettingRepo {
  27. return &mockSettingRepo{data: make(map[string]string)}
  28. }
  29. func (m *mockSettingRepo) Get(_ context.Context, key string) (*Setting, error) {
  30. m.mu.Lock()
  31. defer m.mu.Unlock()
  32. v, ok := m.data[key]
  33. if !ok {
  34. return nil, ErrSettingNotFound
  35. }
  36. return &Setting{Key: key, Value: v}, nil
  37. }
  38. func (m *mockSettingRepo) GetValue(_ context.Context, key string) (string, error) {
  39. m.mu.Lock()
  40. defer m.mu.Unlock()
  41. m.getValueCalls++
  42. if m.getValueErr != nil {
  43. return "", m.getValueErr
  44. }
  45. v, ok := m.data[key]
  46. if !ok {
  47. return "", nil
  48. }
  49. return v, nil
  50. }
  51. func (m *mockSettingRepo) Set(_ context.Context, key, value string) error {
  52. m.mu.Lock()
  53. defer m.mu.Unlock()
  54. m.data[key] = value
  55. return nil
  56. }
  57. func (m *mockSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  58. m.mu.Lock()
  59. defer m.mu.Unlock()
  60. result := make(map[string]string)
  61. for _, k := range keys {
  62. if v, ok := m.data[k]; ok {
  63. result[k] = v
  64. }
  65. }
  66. return result, nil
  67. }
  68. func (m *mockSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
  69. m.mu.Lock()
  70. defer m.mu.Unlock()
  71. for k, v := range settings {
  72. m.data[k] = v
  73. }
  74. return nil
  75. }
  76. func (m *mockSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
  77. m.mu.Lock()
  78. defer m.mu.Unlock()
  79. result := make(map[string]string, len(m.data))
  80. for k, v := range m.data {
  81. result[k] = v
  82. }
  83. return result, nil
  84. }
  85. func (m *mockSettingRepo) Delete(_ context.Context, key string) error {
  86. m.mu.Lock()
  87. defer m.mu.Unlock()
  88. delete(m.data, key)
  89. return nil
  90. }
  91. // plainEncryptor 仅做 base64-like 包装,用于测试
  92. type plainEncryptor struct{}
  93. func (e *plainEncryptor) Encrypt(plaintext string) (string, error) {
  94. return "ENC:" + plaintext, nil
  95. }
  96. func (e *plainEncryptor) Decrypt(ciphertext string) (string, error) {
  97. if strings.HasPrefix(ciphertext, "ENC:") {
  98. return strings.TrimPrefix(ciphertext, "ENC:"), nil
  99. }
  100. return ciphertext, fmt.Errorf("not encrypted")
  101. }
  102. type mockDumper struct {
  103. dumpData []byte
  104. dumpErr error
  105. restored []byte
  106. restErr error
  107. }
  108. func (m *mockDumper) Dump(_ context.Context) (io.ReadCloser, error) {
  109. if m.dumpErr != nil {
  110. return nil, m.dumpErr
  111. }
  112. return io.NopCloser(bytes.NewReader(m.dumpData)), nil
  113. }
  114. func (m *mockDumper) Restore(_ context.Context, data io.Reader) error {
  115. if m.restErr != nil {
  116. return m.restErr
  117. }
  118. d, err := io.ReadAll(data)
  119. if err != nil {
  120. return err
  121. }
  122. m.restored = d
  123. return nil
  124. }
  125. // blockingDumper 可控延迟的 dumper,用于测试异步行为
  126. type blockingDumper struct {
  127. blockCh chan struct{}
  128. data []byte
  129. restErr error
  130. }
  131. func (d *blockingDumper) Dump(ctx context.Context) (io.ReadCloser, error) {
  132. select {
  133. case <-d.blockCh:
  134. case <-ctx.Done():
  135. return nil, ctx.Err()
  136. }
  137. return io.NopCloser(bytes.NewReader(d.data)), nil
  138. }
  139. func (d *blockingDumper) Restore(_ context.Context, data io.Reader) error {
  140. if d.restErr != nil {
  141. return d.restErr
  142. }
  143. _, _ = io.ReadAll(data)
  144. return nil
  145. }
  146. type mockObjectStore struct {
  147. objects map[string][]byte
  148. mu sync.Mutex
  149. failUploadFileAt int
  150. uploadFileCalls int
  151. deletedKeys []string
  152. failDeleteKeys map[string]error
  153. }
  154. type cancelingUploadFailureStore struct {
  155. *mockObjectStore
  156. cancel context.CancelFunc
  157. }
  158. func (m *cancelingUploadFailureStore) UploadFile(_ context.Context, key string, filePath string, _ string) (int64, error) {
  159. file, err := os.Open(filePath)
  160. if err != nil {
  161. return 0, err
  162. }
  163. data, readErr := io.ReadAll(file)
  164. closeErr := file.Close()
  165. if readErr != nil {
  166. return 0, readErr
  167. }
  168. if closeErr != nil {
  169. return 0, closeErr
  170. }
  171. m.mu.Lock()
  172. m.objects[key] = data
  173. m.mu.Unlock()
  174. m.cancel()
  175. return 0, fmt.Errorf("injected upload failure after object landed")
  176. }
  177. func (m *cancelingUploadFailureStore) Delete(ctx context.Context, key string) error {
  178. if err := ctx.Err(); err != nil {
  179. return err
  180. }
  181. return m.mockObjectStore.Delete(ctx, key)
  182. }
  183. func newMockObjectStore() *mockObjectStore {
  184. return &mockObjectStore{objects: make(map[string][]byte), failDeleteKeys: make(map[string]error)}
  185. }
  186. func (m *mockObjectStore) Upload(_ context.Context, key string, body io.Reader, _ string) (int64, error) {
  187. data, err := io.ReadAll(body)
  188. if err != nil {
  189. return 0, err
  190. }
  191. m.mu.Lock()
  192. m.objects[key] = data
  193. m.mu.Unlock()
  194. return int64(len(data)), nil
  195. }
  196. func (m *mockObjectStore) UploadFile(ctx context.Context, key string, filePath string, contentType string) (int64, error) {
  197. m.mu.Lock()
  198. m.uploadFileCalls++
  199. call := m.uploadFileCalls
  200. failAt := m.failUploadFileAt
  201. m.mu.Unlock()
  202. if failAt > 0 && call == failAt {
  203. return 0, fmt.Errorf("injected upload failure at call %d", call)
  204. }
  205. file, err := os.Open(filePath)
  206. if err != nil {
  207. return 0, err
  208. }
  209. defer func() { _ = file.Close() }()
  210. return m.Upload(ctx, key, file, contentType)
  211. }
  212. func (m *mockObjectStore) Download(_ context.Context, key string) (io.ReadCloser, error) {
  213. m.mu.Lock()
  214. data, ok := m.objects[key]
  215. m.mu.Unlock()
  216. if !ok {
  217. return nil, fmt.Errorf("not found: %s", key)
  218. }
  219. return io.NopCloser(bytes.NewReader(data)), nil
  220. }
  221. func (m *mockObjectStore) Delete(_ context.Context, key string) error {
  222. m.mu.Lock()
  223. m.deletedKeys = append(m.deletedKeys, key)
  224. if err, ok := m.failDeleteKeys[key]; ok {
  225. m.mu.Unlock()
  226. return err
  227. }
  228. delete(m.objects, key)
  229. m.mu.Unlock()
  230. return nil
  231. }
  232. func (m *mockObjectStore) PresignURL(_ context.Context, key string, _ time.Duration) (string, error) {
  233. return "https://presigned.example.com/" + key, nil
  234. }
  235. func (m *mockObjectStore) HeadBucket(_ context.Context) error {
  236. return nil
  237. }
  238. func newTestBackupService(repo *mockSettingRepo, dumper DBDumper, store *mockObjectStore) *BackupService {
  239. cfg := &config.Config{
  240. Database: config.DatabaseConfig{
  241. Host: "localhost",
  242. Port: 5432,
  243. User: "test",
  244. DBName: "testdb",
  245. },
  246. // A fixed encryption key is the supported production posture: persisting
  247. // an S3 secret requires it (#4524).
  248. Totp: config.TotpConfig{EncryptionKeyConfigured: true},
  249. }
  250. factory := func(_ context.Context, _ *BackupS3Config) (BackupObjectStore, error) {
  251. return store, nil
  252. }
  253. return NewBackupService(repo, cfg, &plainEncryptor{}, factory, dumper)
  254. }
  255. // newTestBackupServiceEphemeralKey mirrors a deployment that never set
  256. // TOTP_ENCRYPTION_KEY, so the secret encryption key is auto-generated.
  257. func newTestBackupServiceEphemeralKey(repo *mockSettingRepo) *BackupService {
  258. cfg := &config.Config{
  259. Database: config.DatabaseConfig{Host: "localhost", Port: 5432, User: "test", DBName: "testdb"},
  260. Totp: config.TotpConfig{EncryptionKeyConfigured: false},
  261. }
  262. factory := func(_ context.Context, _ *BackupS3Config) (BackupObjectStore, error) {
  263. return newMockObjectStore(), nil
  264. }
  265. return NewBackupService(repo, cfg, &plainEncryptor{}, factory, &mockDumper{})
  266. }
  267. func seedS3Config(t *testing.T, repo *mockSettingRepo) {
  268. t.Helper()
  269. cfg := BackupS3Config{
  270. Bucket: "test-bucket",
  271. AccessKeyID: "AKID",
  272. SecretAccessKey: "ENC:secret123",
  273. Prefix: "backups",
  274. }
  275. data, _ := json.Marshal(cfg)
  276. require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data)))
  277. }
  278. // ─── Tests ───
  279. func TestBackupService_S3ConfigEncryption(t *testing.T) {
  280. repo := newMockSettingRepo()
  281. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  282. // 保存配置 -> SecretAccessKey 应被加密
  283. _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
  284. Bucket: "my-bucket",
  285. AccessKeyID: "AKID",
  286. SecretAccessKey: "my-secret",
  287. Prefix: "backups",
  288. })
  289. require.NoError(t, err)
  290. // 直接读取数据库中存储的值,应该是加密后的
  291. raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config)
  292. var stored BackupS3Config
  293. require.NoError(t, json.Unmarshal([]byte(raw), &stored))
  294. require.Equal(t, "ENC:my-secret", stored.SecretAccessKey)
  295. // 通过 GetS3Config 获取应该脱敏
  296. cfg, err := svc.GetS3Config(context.Background())
  297. require.NoError(t, err)
  298. require.Empty(t, cfg.SecretAccessKey)
  299. require.Equal(t, "my-bucket", cfg.Bucket)
  300. // loadS3Config 内部应解密
  301. internal, err := svc.loadS3Config(context.Background())
  302. require.NoError(t, err)
  303. require.Equal(t, "my-secret", internal.SecretAccessKey)
  304. }
  305. func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) {
  306. repo := newMockSettingRepo()
  307. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  308. // 先保存一个有 secret 的配置
  309. _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
  310. Bucket: "my-bucket",
  311. AccessKeyID: "AKID",
  312. SecretAccessKey: "original-secret",
  313. })
  314. require.NoError(t, err)
  315. // 再更新时不提供 secret,应保留原值
  316. _, err = svc.UpdateS3Config(context.Background(), BackupS3Config{
  317. Bucket: "my-bucket",
  318. AccessKeyID: "AKID-NEW",
  319. })
  320. require.NoError(t, err)
  321. internal, err := svc.loadS3Config(context.Background())
  322. require.NoError(t, err)
  323. require.Equal(t, "original-secret", internal.SecretAccessKey)
  324. require.Equal(t, "AKID-NEW", internal.AccessKeyID)
  325. }
  326. func TestBackupService_UpdateS3Config_RejectsEphemeralKey(t *testing.T) {
  327. repo := newMockSettingRepo()
  328. svc := newTestBackupServiceEphemeralKey(repo)
  329. // 提供新 secret 但密钥为自动生成 -> 必须拒绝,避免重启后无法解密(#4524)。
  330. _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
  331. Bucket: "my-bucket",
  332. AccessKeyID: "AKID",
  333. SecretAccessKey: "my-secret",
  334. Prefix: "backups",
  335. })
  336. require.ErrorIs(t, err, ErrSecretEncryptionKeyNotConfigured)
  337. // 不应写入任何配置。
  338. raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config)
  339. require.Empty(t, raw)
  340. }
  341. func TestBackupService_UpdateS3Config_NoSecretAllowedWithEphemeralKey(t *testing.T) {
  342. repo := newMockSettingRepo()
  343. svc := newTestBackupServiceEphemeralKey(repo)
  344. // 不含 secret 的更新(如只改 bucket)不触碰加密路径,应放行。
  345. _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
  346. Bucket: "my-bucket",
  347. AccessKeyID: "AKID",
  348. })
  349. require.NoError(t, err)
  350. }
  351. func TestBackupService_EncryptionKeyConfigured(t *testing.T) {
  352. repo := newMockSettingRepo()
  353. require.True(t, newTestBackupService(repo, &mockDumper{}, newMockObjectStore()).EncryptionKeyConfigured())
  354. require.False(t, newTestBackupServiceEphemeralKey(repo).EncryptionKeyConfigured())
  355. }
  356. func TestBackupService_SaveRecordConcurrency(t *testing.T) {
  357. repo := newMockSettingRepo()
  358. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  359. var wg sync.WaitGroup
  360. n := 20
  361. wg.Add(n)
  362. for i := 0; i < n; i++ {
  363. go func(idx int) {
  364. defer wg.Done()
  365. record := &BackupRecord{
  366. ID: fmt.Sprintf("rec-%d", idx),
  367. Status: "completed",
  368. StartedAt: time.Now().Format(time.RFC3339),
  369. }
  370. _ = svc.saveRecord(context.Background(), record)
  371. }(i)
  372. }
  373. wg.Wait()
  374. records, err := svc.loadRecords(context.Background())
  375. require.NoError(t, err)
  376. require.Len(t, records, n)
  377. }
  378. func TestBackupService_LoadRecords_Empty(t *testing.T) {
  379. repo := newMockSettingRepo()
  380. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  381. records, err := svc.loadRecords(context.Background())
  382. require.NoError(t, err)
  383. require.Nil(t, records) // 无数据时返回 nil
  384. }
  385. func TestBackupService_LoadRecords_Corrupted(t *testing.T) {
  386. repo := newMockSettingRepo()
  387. _ = repo.Set(context.Background(), settingKeyBackupRecords, "not valid json{{{")
  388. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  389. records, err := svc.loadRecords(context.Background())
  390. require.Error(t, err) // 损坏数据应返回错误
  391. require.Nil(t, records)
  392. }
  393. func TestBackupService_CreateBackup_Streaming(t *testing.T) {
  394. repo := newMockSettingRepo()
  395. seedS3Config(t, repo)
  396. dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
  397. dumper := &mockDumper{dumpData: []byte(dumpContent)}
  398. store := newMockObjectStore()
  399. svc := newTestBackupService(repo, dumper, store)
  400. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  401. require.NoError(t, err)
  402. require.Equal(t, "completed", record.Status)
  403. require.Greater(t, record.SizeBytes, int64(0))
  404. require.NotEmpty(t, record.S3Key)
  405. // 验证 S3 上确实有文件
  406. store.mu.Lock()
  407. require.Len(t, store.objects, 1)
  408. store.mu.Unlock()
  409. }
  410. func TestBackupService_CreateBackup_SplitsCompressedArchive(t *testing.T) {
  411. repo := newMockSettingRepo()
  412. seedS3Config(t, repo)
  413. dumpContent := entropyBackupFixture(512)
  414. dumper := &mockDumper{dumpData: dumpContent}
  415. store := newMockObjectStore()
  416. svc := newTestBackupService(repo, dumper, store)
  417. svc.partSizeBytes = 32
  418. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  419. require.NoError(t, err)
  420. require.Equal(t, "completed", record.Status)
  421. require.Greater(t, len(record.Parts), 1)
  422. require.Empty(t, record.S3Key)
  423. var compressed bytes.Buffer
  424. store.mu.Lock()
  425. for _, part := range record.Parts {
  426. data, ok := store.objects[part.S3Key]
  427. require.True(t, ok)
  428. require.LessOrEqual(t, len(data), 32)
  429. compressed.Write(data)
  430. }
  431. store.mu.Unlock()
  432. gzReader, err := gzip.NewReader(bytes.NewReader(compressed.Bytes()))
  433. require.NoError(t, err)
  434. decompressed, err := io.ReadAll(gzReader)
  435. require.NoError(t, err)
  436. require.NoError(t, gzReader.Close())
  437. require.Equal(t, dumpContent, decompressed)
  438. }
  439. func TestBackupService_StartBackup_SplitsCompressedArchive(t *testing.T) {
  440. repo := newMockSettingRepo()
  441. seedS3Config(t, repo)
  442. store := newMockObjectStore()
  443. svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store)
  444. svc.partSizeBytes = 32
  445. record, err := svc.StartBackup(context.Background(), "manual", 14)
  446. require.NoError(t, err)
  447. svc.wg.Wait()
  448. final, err := svc.GetBackupRecord(context.Background(), record.ID)
  449. require.NoError(t, err)
  450. require.Equal(t, "completed", final.Status)
  451. require.Greater(t, len(final.Parts), 1)
  452. require.Empty(t, final.S3Key)
  453. }
  454. func TestBackupService_StartBackup_UploadFailureCleansUploadedParts(t *testing.T) {
  455. repo := newMockSettingRepo()
  456. seedS3Config(t, repo)
  457. store := newMockObjectStore()
  458. store.failUploadFileAt = 2
  459. svc := newTestBackupService(repo, &mockDumper{dumpData: entropyBackupFixture(512)}, store)
  460. svc.partSizeBytes = 32
  461. record, err := svc.StartBackup(context.Background(), "manual", 14)
  462. require.NoError(t, err)
  463. svc.wg.Wait()
  464. final, err := svc.GetBackupRecord(context.Background(), record.ID)
  465. require.NoError(t, err)
  466. require.Equal(t, "failed", final.Status)
  467. require.NotEmpty(t, final.Parts)
  468. store.mu.Lock()
  469. deletedKeys := append([]string(nil), store.deletedKeys...)
  470. store.mu.Unlock()
  471. for _, part := range final.Parts {
  472. require.Contains(t, deletedKeys, part.S3Key)
  473. }
  474. }
  475. func TestBackupService_UploadFailureCleanupUsesDetachedContext(t *testing.T) {
  476. repo := newMockSettingRepo()
  477. seedS3Config(t, repo)
  478. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  479. svc.partSizeBytes = 4
  480. archive, err := os.CreateTemp("", "backup-upload-context-*.gz")
  481. require.NoError(t, err)
  482. archivePath := archive.Name()
  483. defer func() { _ = os.Remove(archivePath) }()
  484. _, err = archive.Write([]byte("0123456789"))
  485. require.NoError(t, err)
  486. require.NoError(t, archive.Close())
  487. ctx, cancel := context.WithCancel(context.Background())
  488. store := &cancelingUploadFailureStore{
  489. mockObjectStore: newMockObjectStore(),
  490. cancel: cancel,
  491. }
  492. record := &BackupRecord{ID: "cancel-cleanup", S3Key: "backups/cancel-cleanup.sql.gz"}
  493. err = svc.uploadBackupArchive(ctx, record, store, &BackupS3Config{Prefix: "backups"}, archivePath)
  494. require.Error(t, err)
  495. require.NotContains(t, err.Error(), "context canceled")
  496. store.mu.Lock()
  497. defer store.mu.Unlock()
  498. for _, part := range record.Parts {
  499. require.Contains(t, store.deletedKeys, part.S3Key)
  500. require.NotContains(t, store.objects, part.S3Key)
  501. }
  502. }
  503. func entropyBackupFixture(size int) []byte {
  504. data := make([]byte, size)
  505. for i := range data {
  506. data[i] = byte((i*31 + 17) % 251)
  507. }
  508. return data
  509. }
  510. func TestBackupService_CreateBackup_DumpFailure(t *testing.T) {
  511. repo := newMockSettingRepo()
  512. seedS3Config(t, repo)
  513. dumper := &mockDumper{dumpErr: fmt.Errorf("pg_dump failed")}
  514. store := newMockObjectStore()
  515. svc := newTestBackupService(repo, dumper, store)
  516. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  517. require.Error(t, err)
  518. require.Equal(t, "failed", record.Status)
  519. require.Contains(t, record.ErrorMsg, "pg_dump")
  520. }
  521. func TestBackupService_CreateBackup_NoS3Config(t *testing.T) {
  522. repo := newMockSettingRepo()
  523. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  524. _, err := svc.CreateBackup(context.Background(), "manual", 14)
  525. require.ErrorIs(t, err, ErrBackupS3NotConfigured)
  526. }
  527. func TestBackupService_CreateBackup_ConcurrentBlocked(t *testing.T) {
  528. repo := newMockSettingRepo()
  529. seedS3Config(t, repo)
  530. // 使用一个慢速 dumper 来模拟正在进行的备份
  531. dumper := &mockDumper{dumpData: []byte("data")}
  532. store := newMockObjectStore()
  533. svc := newTestBackupService(repo, dumper, store)
  534. // 手动设置 backingUp 标志
  535. svc.opMu.Lock()
  536. svc.backingUp = true
  537. svc.opMu.Unlock()
  538. _, err := svc.CreateBackup(context.Background(), "manual", 14)
  539. require.ErrorIs(t, err, ErrBackupInProgress)
  540. }
  541. // TestBackupService_RunScheduledBackup_LeaderElection verifies the scheduled
  542. // backup is gated by a cross-instance leader lock: a non-leader instance skips
  543. // the dump entirely so a clustered deployment does not run N identical backups
  544. // against the same database, while the leader runs it and releases the lock
  545. // afterward. Manual backups (CreateBackup/StartBackup) are intentionally left
  546. // ungated and are covered by the other tests.
  547. func TestBackupService_RunScheduledBackup_LeaderElection(t *testing.T) {
  548. t.Run("non-leader skips", func(t *testing.T) {
  549. repo := newMockSettingRepo()
  550. seedS3Config(t, repo)
  551. store := newMockObjectStore()
  552. svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, store)
  553. // A peer already owns the lock, so this instance is not the leader.
  554. cache := &fakeLeaderLockCache{}
  555. peerRelease, ok := tryAcquireSingletonLeaderLock(context.Background(), cache, nil, backupScheduledLeaderLockKey, "peer", time.Minute)
  556. require.True(t, ok)
  557. defer peerRelease()
  558. svc.SetLeaderLock(cache, nil)
  559. svc.runScheduledBackup()
  560. store.mu.Lock()
  561. require.Empty(t, store.objects, "non-leader must not upload a backup")
  562. store.mu.Unlock()
  563. records, err := svc.ListBackups(context.Background())
  564. require.NoError(t, err)
  565. require.Empty(t, records, "non-leader must not create a backup record")
  566. require.Equal(t, "peer", cache.heldBy(backupScheduledLeaderLockKey), "peer keeps the lock")
  567. })
  568. t.Run("leader runs and releases", func(t *testing.T) {
  569. repo := newMockSettingRepo()
  570. seedS3Config(t, repo)
  571. store := newMockObjectStore()
  572. svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("-- dump\n")}, store)
  573. cache := &fakeLeaderLockCache{}
  574. svc.SetLeaderLock(cache, nil)
  575. svc.runScheduledBackup()
  576. records, err := svc.ListBackups(context.Background())
  577. require.NoError(t, err)
  578. require.Len(t, records, 1, "leader creates exactly one backup record")
  579. require.Equal(t, "completed", records[0].Status)
  580. require.Empty(t, cache.heldBy(backupScheduledLeaderLockKey), "leader releases the lock when done")
  581. })
  582. }
  583. func TestBackupService_RestoreBackup_Streaming(t *testing.T) {
  584. repo := newMockSettingRepo()
  585. seedS3Config(t, repo)
  586. dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
  587. dumper := &mockDumper{dumpData: []byte(dumpContent)}
  588. store := newMockObjectStore()
  589. svc := newTestBackupService(repo, dumper, store)
  590. // 先创建一个备份
  591. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  592. require.NoError(t, err)
  593. // 恢复
  594. err = svc.RestoreBackup(context.Background(), record.ID)
  595. require.NoError(t, err)
  596. // 验证 psql 收到的数据是否与原始 dump 内容一致
  597. require.Equal(t, dumpContent, string(dumper.restored))
  598. }
  599. func TestBackupService_RestoreBackup_SplitParts(t *testing.T) {
  600. repo := newMockSettingRepo()
  601. seedS3Config(t, repo)
  602. dumpContent := entropyBackupFixture(512)
  603. dumper := &mockDumper{}
  604. store := newMockObjectStore()
  605. svc := newTestBackupService(repo, dumper, store)
  606. compressed := gzipBackupBytes(t, dumpContent)
  607. parts := splitBackupBytes(compressed, 11)
  608. recordParts := make([]BackupPart, 0, len(parts))
  609. for i, data := range parts {
  610. key := fmt.Sprintf("backups/split-1/payload.part-%06d", i+1)
  611. store.objects[key] = data
  612. recordParts = append(recordParts, BackupPart{
  613. Index: i + 1,
  614. S3Key: key,
  615. SizeBytes: int64(len(data)),
  616. SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
  617. })
  618. }
  619. record := &BackupRecord{
  620. ID: "split-1",
  621. Status: "completed",
  622. Parts: recordParts,
  623. SizeBytes: int64(len(compressed)),
  624. }
  625. require.NoError(t, svc.saveRecord(context.Background(), record))
  626. require.NoError(t, svc.RestoreBackup(context.Background(), record.ID))
  627. require.Equal(t, dumpContent, dumper.restored)
  628. }
  629. func TestBackupService_RestoreBackup_SplitPartsMissingPartDoesNotRestore(t *testing.T) {
  630. repo := newMockSettingRepo()
  631. seedS3Config(t, repo)
  632. dumpContent := entropyBackupFixture(256)
  633. dumper := &mockDumper{}
  634. store := newMockObjectStore()
  635. svc := newTestBackupService(repo, dumper, store)
  636. compressed := gzipBackupBytes(t, dumpContent)
  637. parts := splitBackupBytes(compressed, 11)
  638. recordParts := make([]BackupPart, 0, len(parts))
  639. for i, data := range parts {
  640. key := fmt.Sprintf("backups/split-missing/payload.part-%06d", i+1)
  641. store.objects[key] = data
  642. recordParts = append(recordParts, BackupPart{
  643. Index: i + 1,
  644. S3Key: key,
  645. SizeBytes: int64(len(data)),
  646. SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
  647. })
  648. }
  649. delete(store.objects, recordParts[1].S3Key)
  650. record := &BackupRecord{ID: "split-missing", Status: "completed", Parts: recordParts}
  651. require.NoError(t, svc.saveRecord(context.Background(), record))
  652. require.Error(t, svc.RestoreBackup(context.Background(), record.ID))
  653. require.Empty(t, dumper.restored)
  654. }
  655. func TestBackupService_DownloadBackupPartsRejectsMismatchedMetadata(t *testing.T) {
  656. tests := []struct {
  657. name string
  658. part BackupPart
  659. want string
  660. }{
  661. {
  662. name: "size",
  663. part: BackupPart{Index: 1, S3Key: "backups/mismatch/size", SizeBytes: 4},
  664. want: "size mismatch",
  665. },
  666. {
  667. name: "checksum",
  668. part: BackupPart{Index: 1, S3Key: "backups/mismatch/checksum", SizeBytes: 3, SHA256: "bad-checksum"},
  669. want: "checksum mismatch",
  670. },
  671. }
  672. for _, tt := range tests {
  673. t.Run(tt.name, func(t *testing.T) {
  674. repo := newMockSettingRepo()
  675. seedS3Config(t, repo)
  676. store := newMockObjectStore()
  677. store.objects[tt.part.S3Key] = []byte("abc")
  678. svc := newTestBackupService(repo, &mockDumper{}, store)
  679. _, err := svc.downloadBackupParts(context.Background(), store, []BackupPart{tt.part})
  680. require.ErrorContains(t, err, tt.want)
  681. })
  682. }
  683. }
  684. func gzipBackupBytes(t *testing.T, content []byte) []byte {
  685. t.Helper()
  686. var out bytes.Buffer
  687. writer := gzip.NewWriter(&out)
  688. _, err := writer.Write(content)
  689. require.NoError(t, err)
  690. require.NoError(t, writer.Close())
  691. return out.Bytes()
  692. }
  693. func splitBackupBytes(data []byte, partSize int) [][]byte {
  694. parts := make([][]byte, 0, (len(data)+partSize-1)/partSize)
  695. for len(data) > 0 {
  696. size := partSize
  697. if len(data) < size {
  698. size = len(data)
  699. }
  700. parts = append(parts, append([]byte(nil), data[:size]...))
  701. data = data[size:]
  702. }
  703. return parts
  704. }
  705. func TestBackupService_RestoreBackup_NotCompleted(t *testing.T) {
  706. repo := newMockSettingRepo()
  707. seedS3Config(t, repo)
  708. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  709. // 手动插入一条 failed 记录
  710. _ = svc.saveRecord(context.Background(), &BackupRecord{
  711. ID: "fail-1",
  712. Status: "failed",
  713. })
  714. err := svc.RestoreBackup(context.Background(), "fail-1")
  715. require.Error(t, err)
  716. }
  717. func TestBackupService_DeleteBackup(t *testing.T) {
  718. repo := newMockSettingRepo()
  719. seedS3Config(t, repo)
  720. dumpContent := "data"
  721. dumper := &mockDumper{dumpData: []byte(dumpContent)}
  722. store := newMockObjectStore()
  723. svc := newTestBackupService(repo, dumper, store)
  724. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  725. require.NoError(t, err)
  726. // S3 中应有文件
  727. store.mu.Lock()
  728. require.Len(t, store.objects, 1)
  729. store.mu.Unlock()
  730. // 删除
  731. err = svc.DeleteBackup(context.Background(), record.ID)
  732. require.NoError(t, err)
  733. // S3 中文件应被删除
  734. store.mu.Lock()
  735. require.Len(t, store.objects, 0)
  736. store.mu.Unlock()
  737. // 记录应不存在
  738. _, err = svc.GetBackupRecord(context.Background(), record.ID)
  739. require.ErrorIs(t, err, ErrBackupNotFound)
  740. }
  741. func TestBackupService_DeleteBackup_RunningKeepsUploadObjects(t *testing.T) {
  742. repo := newMockSettingRepo()
  743. seedS3Config(t, repo)
  744. store := newMockObjectStore()
  745. svc := newTestBackupService(repo, &mockDumper{}, store)
  746. parts := []BackupPart{
  747. {Index: 1, S3Key: "backups/running/payload.part-000001", SizeBytes: 3},
  748. {Index: 2, S3Key: "backups/running/payload.part-000002", SizeBytes: 3},
  749. }
  750. for _, part := range parts {
  751. store.objects[part.S3Key] = []byte("abc")
  752. }
  753. record := &BackupRecord{ID: "running-delete", Status: "running", Parts: parts}
  754. require.NoError(t, svc.saveRecord(context.Background(), record))
  755. err := svc.DeleteBackup(context.Background(), record.ID)
  756. require.ErrorIs(t, err, ErrBackupInProgress)
  757. store.mu.Lock()
  758. require.Empty(t, store.deletedKeys)
  759. for _, part := range parts {
  760. require.Contains(t, store.objects, part.S3Key)
  761. }
  762. store.mu.Unlock()
  763. got, getErr := svc.GetBackupRecord(context.Background(), record.ID)
  764. require.NoError(t, getErr)
  765. require.Equal(t, "running", got.Status)
  766. }
  767. func TestBackupService_GetDownloadURL(t *testing.T) {
  768. repo := newMockSettingRepo()
  769. seedS3Config(t, repo)
  770. dumper := &mockDumper{dumpData: []byte("data")}
  771. store := newMockObjectStore()
  772. svc := newTestBackupService(repo, dumper, store)
  773. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  774. require.NoError(t, err)
  775. download, err := svc.GetBackupDownloadURL(context.Background(), record.ID)
  776. require.NoError(t, err)
  777. require.Contains(t, download.URL, "https://presigned.example.com/")
  778. }
  779. func TestBackupService_DeleteBackup_SplitPartsFailureKeepsRecord(t *testing.T) {
  780. repo := newMockSettingRepo()
  781. seedS3Config(t, repo)
  782. store := newMockObjectStore()
  783. svc := newTestBackupService(repo, &mockDumper{}, store)
  784. parts := []BackupPart{
  785. {Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 3},
  786. {Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 3},
  787. {Index: 3, S3Key: "backups/split/payload.part-000003", SizeBytes: 3},
  788. }
  789. for _, part := range parts {
  790. store.objects[part.S3Key] = []byte("abc")
  791. }
  792. store.failDeleteKeys[parts[1].S3Key] = fmt.Errorf("delete failed")
  793. record := &BackupRecord{ID: "split-delete", Status: "completed", Parts: parts}
  794. require.NoError(t, svc.saveRecord(context.Background(), record))
  795. err := svc.DeleteBackup(context.Background(), record.ID)
  796. require.Error(t, err)
  797. store.mu.Lock()
  798. deleted := append([]string(nil), store.deletedKeys...)
  799. store.mu.Unlock()
  800. for _, part := range parts {
  801. require.Contains(t, deleted, part.S3Key)
  802. }
  803. got, getErr := svc.GetBackupRecord(context.Background(), record.ID)
  804. require.NoError(t, getErr)
  805. require.Equal(t, record.ID, got.ID)
  806. store.mu.Lock()
  807. require.Contains(t, store.objects, parts[1].S3Key)
  808. store.mu.Unlock()
  809. }
  810. func TestBackupService_GetDownloadURL_SplitParts(t *testing.T) {
  811. repo := newMockSettingRepo()
  812. seedS3Config(t, repo)
  813. store := newMockObjectStore()
  814. svc := newTestBackupService(repo, &mockDumper{}, store)
  815. parts := []BackupPart{
  816. {Index: 2, S3Key: "backups/split/payload.part-000002", SizeBytes: 7},
  817. {Index: 1, S3Key: "backups/split/payload.part-000001", SizeBytes: 5},
  818. }
  819. record := &BackupRecord{ID: "split-download", Status: "completed", Parts: parts}
  820. require.NoError(t, svc.saveRecord(context.Background(), record))
  821. download, err := svc.GetBackupDownloadURL(context.Background(), record.ID)
  822. require.NoError(t, err)
  823. require.Empty(t, download.URL)
  824. require.Len(t, download.Parts, 2)
  825. require.Equal(t, 1, download.Parts[0].Index)
  826. require.Equal(t, int64(5), download.Parts[0].SizeBytes)
  827. require.Equal(t, "https://presigned.example.com/backups/split/payload.part-000001", download.Parts[0].URL)
  828. require.Equal(t, 2, download.Parts[1].Index)
  829. }
  830. func TestBackupService_CleanupOldBackups_SplitParts(t *testing.T) {
  831. repo := newMockSettingRepo()
  832. seedS3Config(t, repo)
  833. store := newMockObjectStore()
  834. svc := newTestBackupService(repo, &mockDumper{}, store)
  835. now := time.Now()
  836. parts := []BackupPart{
  837. {Index: 1, S3Key: "backups/old/payload.part-000001", SizeBytes: 3},
  838. {Index: 2, S3Key: "backups/old/payload.part-000002", SizeBytes: 3},
  839. }
  840. for _, part := range parts {
  841. store.objects[part.S3Key] = []byte("abc")
  842. }
  843. require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
  844. ID: "new",
  845. Status: "completed",
  846. StartedAt: now.Format(time.RFC3339),
  847. }))
  848. require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
  849. ID: "old",
  850. Status: "completed",
  851. StartedAt: now.Add(-2 * time.Hour).Format(time.RFC3339),
  852. Parts: parts,
  853. }))
  854. err := svc.cleanupOldBackups(context.Background(), &BackupScheduleConfig{RetainCount: 1})
  855. require.NoError(t, err)
  856. _, err = svc.GetBackupRecord(context.Background(), "old")
  857. require.ErrorIs(t, err, ErrBackupNotFound)
  858. store.mu.Lock()
  859. for _, part := range parts {
  860. require.NotContains(t, store.objects, part.S3Key)
  861. }
  862. store.mu.Unlock()
  863. }
  864. func TestBackupService_ListBackups_Sorted(t *testing.T) {
  865. repo := newMockSettingRepo()
  866. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  867. now := time.Now()
  868. for i := 0; i < 3; i++ {
  869. _ = svc.saveRecord(context.Background(), &BackupRecord{
  870. ID: fmt.Sprintf("rec-%d", i),
  871. Status: "completed",
  872. StartedAt: now.Add(time.Duration(i) * time.Hour).Format(time.RFC3339),
  873. })
  874. }
  875. records, err := svc.ListBackups(context.Background())
  876. require.NoError(t, err)
  877. require.Len(t, records, 3)
  878. // 最新在前
  879. require.Equal(t, "rec-2", records[0].ID)
  880. require.Equal(t, "rec-0", records[2].ID)
  881. }
  882. func TestBackupService_TestS3Connection(t *testing.T) {
  883. repo := newMockSettingRepo()
  884. store := newMockObjectStore()
  885. svc := newTestBackupService(repo, &mockDumper{}, store)
  886. err := svc.TestS3Connection(context.Background(), BackupS3Config{
  887. Bucket: "test",
  888. AccessKeyID: "ak",
  889. SecretAccessKey: "sk",
  890. })
  891. require.NoError(t, err)
  892. }
  893. func TestBackupService_TestS3Connection_Incomplete(t *testing.T) {
  894. repo := newMockSettingRepo()
  895. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  896. err := svc.TestS3Connection(context.Background(), BackupS3Config{
  897. Bucket: "test",
  898. })
  899. require.Error(t, err)
  900. require.Contains(t, err.Error(), "incomplete")
  901. }
  902. func TestBackupService_Schedule_CronValidation(t *testing.T) {
  903. repo := newMockSettingRepo()
  904. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  905. svc.cronSched = nil // 未初始化 cron
  906. // 启用但 cron 为空
  907. _, err := svc.UpdateSchedule(context.Background(), BackupScheduleConfig{
  908. Enabled: true,
  909. CronExpr: "",
  910. })
  911. require.Error(t, err)
  912. // 无效的 cron 表达式
  913. _, err = svc.UpdateSchedule(context.Background(), BackupScheduleConfig{
  914. Enabled: true,
  915. CronExpr: "invalid",
  916. })
  917. require.Error(t, err)
  918. }
  919. func TestBackupService_LoadS3Config_Corrupted(t *testing.T) {
  920. repo := newMockSettingRepo()
  921. _ = repo.Set(context.Background(), settingKeyBackupS3Config, "not json!!!!")
  922. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  923. cfg, err := svc.loadS3Config(context.Background())
  924. require.Error(t, err)
  925. require.Nil(t, cfg)
  926. }
  927. // ─── Async Backup Tests ───
  928. func TestStartBackup_ReturnsImmediately(t *testing.T) {
  929. repo := newMockSettingRepo()
  930. seedS3Config(t, repo)
  931. dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
  932. store := newMockObjectStore()
  933. svc := newTestBackupService(repo, dumper, store)
  934. record, err := svc.StartBackup(context.Background(), "manual", 14)
  935. require.NoError(t, err)
  936. require.Equal(t, "running", record.Status)
  937. require.NotEmpty(t, record.ID)
  938. // 释放 dumper 让后台完成
  939. close(dumper.blockCh)
  940. svc.wg.Wait()
  941. // 验证最终状态
  942. final, err := svc.GetBackupRecord(context.Background(), record.ID)
  943. require.NoError(t, err)
  944. require.Equal(t, "completed", final.Status)
  945. require.Greater(t, final.SizeBytes, int64(0))
  946. }
  947. func TestStartBackup_ConcurrentBlocked(t *testing.T) {
  948. repo := newMockSettingRepo()
  949. seedS3Config(t, repo)
  950. dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
  951. store := newMockObjectStore()
  952. svc := newTestBackupService(repo, dumper, store)
  953. // 第一次启动
  954. _, err := svc.StartBackup(context.Background(), "manual", 14)
  955. require.NoError(t, err)
  956. // 第二次应被阻塞
  957. _, err = svc.StartBackup(context.Background(), "manual", 14)
  958. require.ErrorIs(t, err, ErrBackupInProgress)
  959. close(dumper.blockCh)
  960. svc.wg.Wait()
  961. }
  962. func TestStartBackup_ShuttingDown(t *testing.T) {
  963. repo := newMockSettingRepo()
  964. seedS3Config(t, repo)
  965. svc := newTestBackupService(repo, &mockDumper{dumpData: []byte("data")}, newMockObjectStore())
  966. svc.shuttingDown.Store(true)
  967. _, err := svc.StartBackup(context.Background(), "manual", 14)
  968. require.Error(t, err)
  969. require.Contains(t, err.Error(), "shutting down")
  970. }
  971. func TestRecoverStaleRecords(t *testing.T) {
  972. repo := newMockSettingRepo()
  973. svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
  974. // 模拟一条孤立的 running 记录
  975. _ = svc.saveRecord(context.Background(), &BackupRecord{
  976. ID: "stale-1",
  977. Status: "running",
  978. StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
  979. })
  980. // 模拟一条孤立的恢复中记录
  981. _ = svc.saveRecord(context.Background(), &BackupRecord{
  982. ID: "stale-2",
  983. Status: "completed",
  984. RestoreStatus: "running",
  985. StartedAt: time.Now().Add(-1 * time.Hour).Format(time.RFC3339),
  986. })
  987. svc.recoverStaleRecords()
  988. r1, _ := svc.GetBackupRecord(context.Background(), "stale-1")
  989. require.Equal(t, "failed", r1.Status)
  990. require.Contains(t, r1.ErrorMsg, "server restart")
  991. r2, _ := svc.GetBackupRecord(context.Background(), "stale-2")
  992. require.Equal(t, "failed", r2.RestoreStatus)
  993. require.Contains(t, r2.RestoreError, "server restart")
  994. }
  995. func TestBackupService_RecoverStaleRecords_CleansBackupObjects(t *testing.T) {
  996. repo := newMockSettingRepo()
  997. seedS3Config(t, repo)
  998. store := newMockObjectStore()
  999. svc := newTestBackupService(repo, &mockDumper{}, store)
  1000. parts := []BackupPart{
  1001. {Index: 1, S3Key: "backups/stale/payload.part-000001", SizeBytes: 3},
  1002. {Index: 2, S3Key: "backups/stale/payload.part-000002", SizeBytes: 3},
  1003. }
  1004. for _, part := range parts {
  1005. store.objects[part.S3Key] = []byte("abc")
  1006. }
  1007. require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
  1008. ID: "stale-parts",
  1009. Status: "running",
  1010. Parts: parts,
  1011. StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
  1012. }))
  1013. svc.recoverStaleRecords()
  1014. record, err := svc.GetBackupRecord(context.Background(), "stale-parts")
  1015. require.NoError(t, err)
  1016. require.Equal(t, "failed", record.Status)
  1017. store.mu.Lock()
  1018. defer store.mu.Unlock()
  1019. for _, part := range parts {
  1020. require.Contains(t, store.deletedKeys, part.S3Key)
  1021. require.NotContains(t, store.objects, part.S3Key)
  1022. }
  1023. }
  1024. func TestBackupService_RecoverStaleRecords_PreservesKeysWhenCleanupFails(t *testing.T) {
  1025. repo := newMockSettingRepo()
  1026. seedS3Config(t, repo)
  1027. store := newMockObjectStore()
  1028. svc := newTestBackupService(repo, &mockDumper{}, store)
  1029. part := BackupPart{Index: 1, S3Key: "backups/stale-failed/payload.part-000001", SizeBytes: 3}
  1030. store.objects[part.S3Key] = []byte("abc")
  1031. store.failDeleteKeys[part.S3Key] = fmt.Errorf("delete failed")
  1032. require.NoError(t, svc.saveRecord(context.Background(), &BackupRecord{
  1033. ID: "stale-cleanup-failed",
  1034. Status: "running",
  1035. Parts: []BackupPart{part},
  1036. StartedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
  1037. }))
  1038. svc.recoverStaleRecords()
  1039. record, err := svc.GetBackupRecord(context.Background(), "stale-cleanup-failed")
  1040. require.NoError(t, err)
  1041. require.Equal(t, "failed", record.Status)
  1042. require.Contains(t, record.ErrorMsg, "cleanup failed")
  1043. require.Equal(t, part.S3Key, record.Parts[0].S3Key)
  1044. store.mu.Lock()
  1045. defer store.mu.Unlock()
  1046. require.Contains(t, store.objects, part.S3Key)
  1047. }
  1048. func TestGracefulShutdown(t *testing.T) {
  1049. repo := newMockSettingRepo()
  1050. seedS3Config(t, repo)
  1051. dumper := &blockingDumper{blockCh: make(chan struct{}), data: []byte("data")}
  1052. store := newMockObjectStore()
  1053. svc := newTestBackupService(repo, dumper, store)
  1054. _, err := svc.StartBackup(context.Background(), "manual", 14)
  1055. require.NoError(t, err)
  1056. // Stop 应该等待备份完成
  1057. done := make(chan struct{})
  1058. go func() {
  1059. svc.Stop()
  1060. close(done)
  1061. }()
  1062. // 短暂等待确认 Stop 还在等待
  1063. select {
  1064. case <-done:
  1065. t.Fatal("Stop returned before backup finished")
  1066. case <-time.After(100 * time.Millisecond):
  1067. // 预期:Stop 还在等待
  1068. }
  1069. // 释放备份
  1070. close(dumper.blockCh)
  1071. // 现在 Stop 应该完成
  1072. select {
  1073. case <-done:
  1074. // 预期
  1075. case <-time.After(5 * time.Second):
  1076. t.Fatal("Stop did not return after backup finished")
  1077. }
  1078. }
  1079. func TestStartRestore_Async(t *testing.T) {
  1080. repo := newMockSettingRepo()
  1081. seedS3Config(t, repo)
  1082. dumpContent := "-- PostgreSQL dump\nCREATE TABLE test (id int);\n"
  1083. dumper := &mockDumper{dumpData: []byte(dumpContent)}
  1084. store := newMockObjectStore()
  1085. svc := newTestBackupService(repo, dumper, store)
  1086. // 先创建一个备份(同步方式)
  1087. record, err := svc.CreateBackup(context.Background(), "manual", 14)
  1088. require.NoError(t, err)
  1089. // 异步恢复
  1090. restored, err := svc.StartRestore(context.Background(), record.ID)
  1091. require.NoError(t, err)
  1092. require.Equal(t, "running", restored.RestoreStatus)
  1093. svc.wg.Wait()
  1094. // 验证最终状态
  1095. final, err := svc.GetBackupRecord(context.Background(), record.ID)
  1096. require.NoError(t, err)
  1097. require.Equal(t, "completed", final.RestoreStatus)
  1098. }
  1099. func TestBackupService_StartRestore_SplitParts(t *testing.T) {
  1100. repo := newMockSettingRepo()
  1101. seedS3Config(t, repo)
  1102. dumpContent := entropyBackupFixture(384)
  1103. dumper := &mockDumper{}
  1104. store := newMockObjectStore()
  1105. svc := newTestBackupService(repo, dumper, store)
  1106. compressed := gzipBackupBytes(t, dumpContent)
  1107. parts := splitBackupBytes(compressed, 13)
  1108. recordParts := make([]BackupPart, 0, len(parts))
  1109. for i, data := range parts {
  1110. key := fmt.Sprintf("backups/split-async/payload.part-%06d", i+1)
  1111. store.objects[key] = data
  1112. recordParts = append(recordParts, BackupPart{
  1113. Index: i + 1,
  1114. S3Key: key,
  1115. SizeBytes: int64(len(data)),
  1116. SHA256: fmt.Sprintf("%x", sha256.Sum256(data)),
  1117. })
  1118. }
  1119. record := &BackupRecord{ID: "split-async", Status: "completed", Parts: recordParts}
  1120. require.NoError(t, svc.saveRecord(context.Background(), record))
  1121. started, err := svc.StartRestore(context.Background(), record.ID)
  1122. require.NoError(t, err)
  1123. require.Equal(t, "running", started.RestoreStatus)
  1124. svc.wg.Wait()
  1125. final, err := svc.GetBackupRecord(context.Background(), record.ID)
  1126. require.NoError(t, err)
  1127. require.Equal(t, "completed", final.RestoreStatus)
  1128. require.Equal(t, dumpContent, dumper.restored)
  1129. }