fc4ac77529
Co-authored-by: Kandev Agent <agent@kandev.dev>
239 lines
6.8 KiB
Go
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()
|
|
}
|