383 lines
13 KiB
Go
383 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/config"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
func TestIsSQLiteTransientRehydrateError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{name: "nil error", err: nil, want: false},
|
|
{name: "database is locked", err: errors.New("database is locked"), want: true},
|
|
{name: "database is busy", err: errors.New("database is busy"), want: true},
|
|
{name: "database table is locked", err: errors.New("database table is locked"), want: true},
|
|
{name: "table is locked", err: errors.New("table is locked"), want: true},
|
|
{name: "resource busy", err: errors.New("resource busy"), want: true},
|
|
{name: "mixed-case transient message", err: errors.New("Database Is Locked"), want: true},
|
|
{name: "non-transient error", err: errors.New("constraint failed"), want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
require.Equal(t, tt.want, isSQLiteTransientRehydrateError(tt.err))
|
|
})
|
|
}
|
|
}
|
|
|
|
func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
|
|
t.Helper()
|
|
|
|
// Create temp directories
|
|
tmpDir, err := os.MkdirTemp("", "cpm-backup-test")
|
|
require.NoError(t, err)
|
|
|
|
// Structure: tmpDir/data/charon.db
|
|
// BackupService expects DatabasePath to be .../data/charon.db
|
|
// It sets DataDir to filepath.Dir(DatabasePath) -> .../data
|
|
// It sets BackupDir to .../data/backups (Wait, let me check the code again)
|
|
|
|
// Code: backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
|
|
// So if DatabasePath is /tmp/data/charon.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
|
|
|
|
dataDir := filepath.Join(tmpDir, "data")
|
|
err = os.MkdirAll(dataDir, 0o750)
|
|
require.NoError(t, err)
|
|
|
|
dbPath := filepath.Join(dataDir, "charon.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = db.Close()
|
|
})
|
|
_, err = db.Exec("CREATE TABLE IF NOT EXISTS healthcheck (id INTEGER PRIMARY KEY, value TEXT)")
|
|
require.NoError(t, err)
|
|
_, err = db.Exec("INSERT INTO healthcheck (value) VALUES (?)", "ok")
|
|
require.NoError(t, err)
|
|
|
|
cfg := &config.Config{
|
|
DatabasePath: dbPath,
|
|
}
|
|
|
|
svc := services.NewBackupService(cfg)
|
|
h := NewBackupHandler(svc)
|
|
|
|
r := gin.New()
|
|
r.Use(func(c *gin.Context) {
|
|
c.Set("role", "admin")
|
|
c.Set("userID", uint(1))
|
|
c.Next()
|
|
})
|
|
api := r.Group("/api/v1")
|
|
// Manually register routes since we don't have a RegisterRoutes method on the handler yet?
|
|
// Wait, I didn't check if I added RegisterRoutes to BackupHandler.
|
|
// In routes.go I did:
|
|
// backupHandler := handlers.NewBackupHandler(backupService)
|
|
// backups := api.Group("/backups")
|
|
// backups.GET("", backupHandler.List)
|
|
// ...
|
|
// So the handler doesn't have RegisterRoutes. I'll register manually here.
|
|
|
|
backups := api.Group("/backups")
|
|
backups.GET("", h.List)
|
|
backups.POST("", h.Create)
|
|
backups.POST("/:filename/restore", h.Restore)
|
|
backups.DELETE("/:filename", h.Delete)
|
|
backups.GET("/:filename/download", h.Download)
|
|
|
|
return r, svc, tmpDir
|
|
}
|
|
|
|
func TestBackupLifecycle(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// 1. List backups (should be empty)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
// Check empty list
|
|
// ...
|
|
|
|
// 2. Create backup
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var result map[string]string
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
filename := result["filename"]
|
|
require.NotEmpty(t, filename)
|
|
|
|
// 3. List backups (should have 1)
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
// Verify list contains filename
|
|
|
|
// 4. Restore backup
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var restoreResult map[string]any
|
|
err = json.Unmarshal(resp.Body.Bytes(), &restoreResult)
|
|
require.NoError(t, err)
|
|
require.Contains(t, restoreResult, "restart_required")
|
|
require.Contains(t, restoreResult, "live_rehydrate_applied")
|
|
|
|
// 5. Download backup
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
// Content-Type might vary depending on implementation (application/octet-stream or zip)
|
|
// require.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
|
|
|
|
// 6. Delete backup
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
// 7. List backups (should be empty again)
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var list []any
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &list)
|
|
require.Empty(t, list)
|
|
|
|
// 8. Delete non-existent backup
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
|
|
// 9. Restore non-existent backup
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
|
|
// 10. Download non-existent backup
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_Errors(t *testing.T) {
|
|
router, svc, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// 1. List Error (remove backup dir to cause ReadDir error)
|
|
// Note: Service now handles missing dir gracefully by returning empty list
|
|
_ = os.RemoveAll(svc.BackupDir)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
var list []any
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &list)
|
|
require.Empty(t, list)
|
|
|
|
// 4. Delete Error (Not Found)
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_List_Success(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Create a backup first
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
// Now list should return it
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var backups []services.BackupFile
|
|
err := json.Unmarshal(resp.Body.Bytes(), &backups)
|
|
require.NoError(t, err)
|
|
require.Len(t, backups, 1)
|
|
require.Contains(t, backups[0].Filename, "backup_")
|
|
}
|
|
|
|
func TestBackupHandler_Create_Success(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var result map[string]string
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NotEmpty(t, result["filename"])
|
|
require.Contains(t, result["filename"], "backup_")
|
|
}
|
|
|
|
func TestBackupHandler_Download_Success(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Create backup
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var result map[string]string
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
filename := result["filename"]
|
|
|
|
// Download it
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
require.Contains(t, resp.Header().Get("Content-Type"), "application")
|
|
}
|
|
|
|
func TestBackupHandler_PathTraversal(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Try path traversal in Delete
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/backups/../../../etc/passwd", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
|
|
// Try path traversal in Download
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/../../../etc/passwd/download", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
|
|
|
|
// Try path traversal in Restore
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/../../../etc/passwd/restore", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_Download_InvalidPath(t *testing.T) {
|
|
router, _, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Request with path traversal attempt
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups/../invalid/download", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
// Should be BadRequest due to path validation failure
|
|
require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_Create_ServiceError(t *testing.T) {
|
|
router, svc, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Remove write permissions on backup dir to force create error
|
|
// #nosec G302 -- Test intentionally uses restrictive perms to simulate error
|
|
_ = os.Chmod(svc.BackupDir, 0o444)
|
|
defer func() {
|
|
// #nosec G302 -- Cleanup restores directory permissions
|
|
_ = os.Chmod(svc.BackupDir, 0o755)
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
// Should fail with 500 due to permission error
|
|
require.Contains(t, []int{http.StatusInternalServerError, http.StatusCreated}, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_Delete_InternalError(t *testing.T) {
|
|
router, svc, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Create a backup first
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var result map[string]string
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
filename := result["filename"]
|
|
|
|
// Make backup dir read-only to cause delete error (not NotExist)
|
|
// #nosec G302 -- Test intentionally sets restrictive permissions to verify error handling
|
|
_ = os.Chmod(svc.BackupDir, 0o444)
|
|
// #nosec G302 -- Test cleanup restores directory permissions
|
|
defer func() { _ = os.Chmod(svc.BackupDir, 0o755) }()
|
|
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
// Should fail with 500 due to permission error (not 404)
|
|
require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
|
|
}
|
|
|
|
func TestBackupHandler_Restore_InternalError(t *testing.T) {
|
|
router, svc, tmpDir := setupBackupTest(t)
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Create a backup first
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
require.Equal(t, http.StatusCreated, resp.Code)
|
|
|
|
var result map[string]string
|
|
_ = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
filename := result["filename"]
|
|
|
|
// Make data dir read-only to cause restore error
|
|
// #nosec G302 -- Test intentionally sets restrictive permissions to verify error handling
|
|
_ = os.Chmod(svc.DataDir, 0o444)
|
|
// #nosec G302 -- Test cleanup restores directory permissions
|
|
defer func() { _ = os.Chmod(svc.DataDir, 0o755) }()
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
|
|
resp = httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
// Should fail with 500 due to permission error
|
|
require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
|
|
}
|