Files
kandev/apps/backend/internal/system/backups/handler_test.go
T

228 lines
6.3 KiB
Go

package backups
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/system/jobs"
)
func newRouter(svc *Service) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
g := r.Group("/api/v1/system")
RegisterRoutes(g, svc)
return r
}
func TestHandleList_ReturnsSnapshots(t *testing.T) {
svc, dataDir := newTestService(t)
backupsDir := filepath.Join(dataDir, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(backupsDir, "manual-1.db"), []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
r := newRouter(svc)
req := httptest.NewRequest("GET", "/api/v1/system/backups", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d", w.Code)
}
var resp struct {
Snapshots []Snapshot `json:"snapshots"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Snapshots) != 1 || resp.Snapshots[0].Kind != "manual" {
t.Errorf("unexpected snapshots: %+v", resp.Snapshots)
}
}
func TestHandleCreate_Returns202WithJobID(t *testing.T) {
svc, _ := newTestService(t)
r := newRouter(svc)
req := httptest.NewRequest("POST", "/api/v1/system/backups", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["job_id"] == "" {
t.Errorf("expected job_id, body = %s", w.Body.String())
}
waitForJob(t, svc.jobs, resp["job_id"], jobs.StateSucceeded)
}
func TestHandleRestore_WrongConfirmReturns400(t *testing.T) {
svc, _ := newTestService(t)
r := newRouter(svc)
body := bytes.NewBufferString(`{"confirm":"NOPE"}`)
req := httptest.NewRequest("POST", "/api/v1/system/backups/manual-1.db/restore", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}
func TestHandleDelete_RejectsTraversal(t *testing.T) {
svc, _ := newTestService(t)
r := newRouter(svc)
// gin's :name will refuse a slash, but ensure dot-segments are rejected.
req := httptest.NewRequest("DELETE", "/api/v1/system/backups/..", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleDelete_PreResetSnapshotReturns403(t *testing.T) {
svc, dataDir := newTestService(t)
backupsDir := filepath.Join(dataDir, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
name := "kandev-pre-reset-20260101T000000Z.db"
if err := os.WriteFile(filepath.Join(backupsDir, name), []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
r := newRouter(svc)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/"+name, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
}
func TestHandleDelete_MissingSnapshotReturns404(t *testing.T) {
svc, _ := newTestService(t)
r := newRouter(svc)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/manual-missing.db", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestHandleDelete_ManualSnapshotReturns204(t *testing.T) {
svc, dataDir := newTestService(t)
backupsDir := filepath.Join(dataDir, "backups")
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(backupsDir, "manual-9.db"), []byte("x"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
r := newRouter(svc)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/manual-9.db", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", w.Code)
}
if _, err := os.Stat(filepath.Join(backupsDir, "manual-9.db")); !os.IsNotExist(err) {
t.Errorf("expected file removed, err=%v", err)
}
}
func TestHandleDownload_StreamsFile(t *testing.T) {
svc, dataDir := newTestService(t)
if err := os.MkdirAll(filepath.Join(dataDir, "backups"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dataDir, "backups", "manual-7.db"), []byte("payload"), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
r := newRouter(svc)
req := httptest.NewRequest("GET", "/api/v1/system/backups/manual-7.db/download", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Header().Get("Content-Disposition"), "manual-7.db") {
t.Errorf("missing attachment header: %q", w.Header().Get("Content-Disposition"))
}
if w.Body.String() != "payload" {
t.Errorf("body = %q", w.Body.String())
}
}
// Sanity-check the round-trip: create via handler, list via handler, file
// present on disk under manual- prefix.
func TestHandlerRoundTrip_CreateThenList(t *testing.T) {
svc, dataDir := newTestService(t)
r := newRouter(svc)
req := httptest.NewRequest("POST", "/api/v1/system/backups", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var created map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatalf("decode: %v", err)
}
waitForJob(t, svc.jobs, created["job_id"], jobs.StateSucceeded)
// Confirm file appears on disk.
entries, _ := os.ReadDir(filepath.Join(dataDir, "backups"))
found := false
for _, e := range entries {
if strings.HasPrefix(e.Name(), "manual-") {
found = true
}
}
if !found {
t.Fatal("manual snapshot not created")
}
// And shows up in list.
req2 := httptest.NewRequest("GET", "/api/v1/system/backups", nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
var listResp struct {
Snapshots []Snapshot `json:"snapshots"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &listResp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(listResp.Snapshots) < 1 {
t.Errorf("expected >=1 snapshot, got %+v", listResp.Snapshots)
}
_ = context.Background()
}