Files
kandev/apps/backend/internal/system/backups/handler_test.go
T
2026-08-25 15:05:14 +01:00

239 lines
6.8 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/auth/authn"
"github.com/kandev/kandev/internal/system/jobs"
)
// newRouter mirrors the production wiring in internal/system: a read group
// plus an admin group guarded by authn.RequireAdmin. The identity is an admin
// (as the synthetic single-user identity is when auth is disabled), so these
// handler tests exercise behavior rather than the role guard, which
// internal/system's route tests own.
func newRouter(svc *Service) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
authn.SetOnGin(c, authn.Identity{UserID: "admin-1", Role: authn.RoleAdmin})
c.Next()
})
g := r.Group("/api/v1/system")
RegisterRoutes(g, g.Group("", authn.RequireAdmin()), 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()
}