backup_handlers_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. package api
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "mime/multipart"
  9. "net/http"
  10. "net/http/httptest"
  11. "os"
  12. "path/filepath"
  13. "testing"
  14. "debtik/internal/auth"
  15. "debtik/internal/backup"
  16. )
  17. func TestBackupAPIHandlers(t *testing.T) {
  18. tempDir := t.TempDir()
  19. debtikDir := filepath.Join(tempDir, "debtik")
  20. netDir := filepath.Join(tempDir, "network")
  21. storeDir := filepath.Join(tempDir, "backups")
  22. _ = os.MkdirAll(debtikDir, 0755)
  23. _ = os.MkdirAll(netDir, 0755)
  24. _ = os.WriteFile(filepath.Join(debtikDir, "metadata.json"), []byte(`{"router": "test"}`), 0644)
  25. _ = os.WriteFile(filepath.Join(netDir, "interfaces.cfg"), []byte(`auto lo`), 0644)
  26. backupMgr := backup.NewManager(debtikDir, netDir, storeDir, nil)
  27. s := &Server{
  28. backupMgr: backupMgr,
  29. mux: http.NewServeMux(),
  30. fsHandler: http.NotFoundHandler(),
  31. }
  32. s.registerRoutes()
  33. // 1. Test Backup Export (Download)
  34. req := httptest.NewRequest(http.MethodGet, "/api/system/backup/export?comment=APITest", nil)
  35. // Context with full admin role
  36. ctx := context.WithValue(req.Context(), sessionContextKey, &auth.Session{
  37. Username: "admin",
  38. Role: auth.RoleFull,
  39. })
  40. req = req.WithContext(ctx)
  41. rec := httptest.NewRecorder()
  42. s.mux.ServeHTTP(rec, req)
  43. if rec.Code != http.StatusOK {
  44. t.Fatalf("Expected 200 OK from backup export, got %d: %s", rec.Code, rec.Body.String())
  45. }
  46. if rec.Header().Get("Content-Type") != "application/zip" {
  47. t.Errorf("Expected application/zip Content-Type, got %s", rec.Header().Get("Content-Type"))
  48. }
  49. zipBytes := rec.Body.Bytes()
  50. if len(zipBytes) == 0 {
  51. t.Fatal("Exported zip is empty")
  52. }
  53. // 2. Test Backup Inspect via multipart upload
  54. bodyBuf := new(bytes.Buffer)
  55. mpWriter := multipart.NewWriter(bodyBuf)
  56. part, err := mpWriter.CreateFormFile("backup", "test-backup.zip")
  57. if err != nil {
  58. t.Fatal(err)
  59. }
  60. _, _ = io.Copy(part, bytes.NewReader(zipBytes))
  61. _ = mpWriter.Close()
  62. inspectReq := httptest.NewRequest(http.MethodPost, "/api/system/backup/inspect", bodyBuf)
  63. inspectReq.Header.Set("Content-Type", mpWriter.FormDataContentType())
  64. inspectReq = inspectReq.WithContext(ctx)
  65. inspectRec := httptest.NewRecorder()
  66. s.mux.ServeHTTP(inspectRec, inspectReq)
  67. if inspectRec.Code != http.StatusOK {
  68. t.Fatalf("Expected 200 OK from backup inspect, got %d: %s", inspectRec.Code, inspectRec.Body.String())
  69. }
  70. var inspectResp struct {
  71. Success bool `json:"success"`
  72. Manifest *backup.Manifest `json:"manifest"`
  73. }
  74. if err := json.Unmarshal(inspectRec.Body.Bytes(), &inspectResp); err != nil {
  75. t.Fatalf("Failed unmarshaling inspect response: %v", err)
  76. }
  77. if !inspectResp.Success || inspectResp.Manifest == nil {
  78. t.Fatal("Expected success and manifest in inspect response")
  79. }
  80. if inspectResp.Manifest.Comment != "APITest" {
  81. t.Errorf("Manifest comment mismatch: %s != APITest", inspectResp.Manifest.Comment)
  82. }
  83. // 3. Test Backup Restore
  84. restoreBuf := new(bytes.Buffer)
  85. mpWriter2 := multipart.NewWriter(restoreBuf)
  86. part2, _ := mpWriter2.CreateFormFile("backup", "restore-backup.zip")
  87. _, _ = io.Copy(part2, bytes.NewReader(zipBytes))
  88. _ = mpWriter2.Close()
  89. restoreReq := httptest.NewRequest(http.MethodPost, "/api/system/backup/restore", restoreBuf)
  90. restoreReq.Header.Set("Content-Type", mpWriter2.FormDataContentType())
  91. restoreReq = restoreReq.WithContext(ctx)
  92. restoreRec := httptest.NewRecorder()
  93. s.mux.ServeHTTP(restoreRec, restoreReq)
  94. if restoreRec.Code != http.StatusOK {
  95. t.Fatalf("Expected 200 OK from backup restore, got %d: %s", restoreRec.Code, restoreRec.Body.String())
  96. }
  97. var restoreRes backup.RestoreResult
  98. if err := json.Unmarshal(restoreRec.Body.Bytes(), &restoreRes); err != nil {
  99. t.Fatalf("Failed unmarshaling restore response: %v", err)
  100. }
  101. if !restoreRes.Success {
  102. t.Error("Expected restore success true")
  103. }
  104. // 4. Test Backup List (should contain pre-restore safety backup)
  105. listReq := httptest.NewRequest(http.MethodGet, "/api/system/backups", nil)
  106. listReq = listReq.WithContext(ctx)
  107. listRec := httptest.NewRecorder()
  108. s.mux.ServeHTTP(listRec, listReq)
  109. if listRec.Code != http.StatusOK {
  110. t.Fatalf("Expected 200 OK from backup list, got %d: %s", listRec.Code, listRec.Body.String())
  111. }
  112. var backupsList []backup.BackupInfo
  113. if err := json.Unmarshal(listRec.Body.Bytes(), &backupsList); err != nil {
  114. t.Fatalf("Failed unmarshaling backup list: %v", err)
  115. }
  116. if len(backupsList) == 0 {
  117. t.Error("Expected at least 1 backup in list")
  118. }
  119. // 5. Test Forbidden for Read-Only user
  120. roCtx := context.WithValue(req.Context(), sessionContextKey, &auth.Session{
  121. Username: "readonly-user",
  122. Role: auth.RoleRead,
  123. })
  124. roReq := httptest.NewRequest(http.MethodGet, "/api/system/backup/export", nil)
  125. roReq = roReq.WithContext(roCtx)
  126. roRec := httptest.NewRecorder()
  127. s.mux.ServeHTTP(roRec, roReq)
  128. if roRec.Code != http.StatusForbidden {
  129. t.Errorf("Expected 403 Forbidden for read-only user, got %d", roRec.Code)
  130. }
  131. }
  132. func TestBackupUploadSizeLimit(t *testing.T) {
  133. for _, multipartMode := range []bool{false, true} {
  134. t.Run(fmt.Sprintf("multipart=%v", multipartMode), func(t *testing.T) {
  135. var body bytes.Buffer
  136. contentType := "application/zip"
  137. if multipartMode {
  138. w := multipart.NewWriter(&body)
  139. part, err := w.CreateFormFile("backup", "x.zip")
  140. if err != nil {
  141. t.Fatal(err)
  142. }
  143. part.Write(bytes.Repeat([]byte("x"), 512))
  144. w.Close()
  145. contentType = w.FormDataContentType()
  146. } else {
  147. body.Write(bytes.Repeat([]byte("x"), 512))
  148. }
  149. req := httptest.NewRequest(http.MethodPost, "/", &body)
  150. req.Header.Set("Content-Type", contentType)
  151. req.ContentLength = -1
  152. rec := httptest.NewRecorder()
  153. _, err := readUploadedBackupLimit(rec, req, 256)
  154. if err == nil {
  155. t.Fatal("oversized body accepted")
  156. }
  157. writeBackupUploadError(rec, err)
  158. if rec.Code != http.StatusRequestEntityTooLarge {
  159. t.Fatalf("HTTP %d", rec.Code)
  160. }
  161. })
  162. }
  163. }