chore: remove cached

This commit is contained in:
Wikid82
2025-11-24 18:21:11 +00:00
parent 5b041819bb
commit 9c842e7eab
394 changed files with 0 additions and 44139 deletions

View File

@@ -1,111 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := h.authService.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Set cookie
c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
c.JSON(http.StatusOK, gin.H{"token": token})
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.authService.Register(req.Email, req.Password, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, user)
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *AuthHandler) Me(c *gin.Context) {
userID, _ := c.Get("userID")
role, _ := c.Get("role")
u, err := h.authService.GetUserByID(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"role": role,
"name": u.Name,
"email": u.Email,
})
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}

View File

@@ -1,295 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{}, &models.Setting{})
cfg := config.Config{JWTSecret: "test-secret"}
authService := services.NewAuthService(db, cfg)
return NewAuthHandler(authService), db
}
func TestAuthHandler_Login(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user
user := &models.User{
UUID: uuid.NewString(),
Email: "test@example.com",
Name: "Test User",
}
user.SetPassword("password123")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/login", handler.Login)
// Success
body := map[string]string{
"email": "test@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "token")
}
func TestAuthHandler_Login_Errors(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/login", handler.Login)
// 1. Invalid JSON
req := httptest.NewRequest("POST", "/login", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 2. Invalid Credentials
body := map[string]string{
"email": "nonexistent@example.com",
"password": "wrong",
}
jsonBody, _ := json.Marshal(body)
req = httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Register(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/register", handler.Register)
body := map[string]string{
"email": "new@example.com",
"password": "password123",
"name": "New User",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), "new@example.com")
}
func TestAuthHandler_Register_Duplicate(t *testing.T) {
handler, db := setupAuthHandler(t)
db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"})
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/register", handler.Register)
body := map[string]string{
"email": "dup@example.com",
"password": "password123",
"name": "Dup User",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAuthHandler_Logout(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/logout", handler.Logout)
req := httptest.NewRequest("POST", "/logout", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Logged out")
// Check cookie
cookie := w.Result().Cookies()[0]
assert.Equal(t, "auth_token", cookie.Name)
assert.Equal(t, -1, cookie.MaxAge)
}
func TestAuthHandler_Me(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user that matches the middleware ID
user := &models.User{
UUID: uuid.NewString(),
Email: "me@example.com",
Name: "Me User",
Role: "admin",
}
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
// Simulate middleware
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Set("role", user.Role)
c.Next()
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(user.ID), resp["user_id"])
assert.Equal(t, "admin", resp["role"])
assert.Equal(t, "Me User", resp["name"])
assert.Equal(t, "me@example.com", resp["email"])
}
func TestAuthHandler_Me_NotFound(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", uint(999)) // Non-existent ID
c.Next()
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestAuthHandler_ChangePassword(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user
user := &models.User{
UUID: uuid.NewString(),
Email: "change@example.com",
Name: "Change User",
}
user.SetPassword("oldpassword")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
// Simulate middleware
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.POST("/change-password", handler.ChangePassword)
body := map[string]string{
"old_password": "oldpassword",
"new_password": "newpassword123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Password updated successfully")
// Verify password changed
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.True(t, updatedUser.CheckPassword("newpassword123"))
}
func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) {
handler, db := setupAuthHandler(t)
user := &models.User{UUID: uuid.NewString(), Email: "wrong@example.com"}
user.SetPassword("correct")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.POST("/change-password", handler.ChangePassword)
body := map[string]string{
"old_password": "wrong",
"new_password": "newpassword",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAuthHandler_ChangePassword_Errors(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/change-password", handler.ChangePassword)
// 1. BindJSON error (checked before auth)
req, _ := http.NewRequest("POST", "/change-password", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 2. Unauthorized (valid JSON but no user in context)
body := map[string]string{
"old_password": "oldpassword",
"new_password": "newpassword123",
}
jsonBody, _ := json.Marshal(body)
req, _ = http.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

View File

@@ -1,79 +0,0 @@
package handlers
import (
"net/http"
"os"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type BackupHandler struct {
service *services.BackupService
}
func NewBackupHandler(service *services.BackupService) *BackupHandler {
return &BackupHandler{service: service}
}
func (h *BackupHandler) List(c *gin.Context) {
backups, err := h.service.ListBackups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
return
}
c.JSON(http.StatusOK, backups)
}
func (h *BackupHandler) Create(c *gin.Context) {
filename, err := h.service.CreateBackup()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
}
func (h *BackupHandler) Delete(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.DeleteBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
}
func (h *BackupHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetBackupPath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(path); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(path)
}
func (h *BackupHandler) Restore(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.RestoreBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
return
}
// In a real scenario, we might want to trigger a restart here
c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
}

View File

@@ -1,189 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
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/cpm.db
// BackupService expects DatabasePath to be .../data/cpm.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/cpm.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
dataDir := filepath.Join(tmpDir, "data")
err = os.MkdirAll(dataDir, 0755)
require.NoError(t, err)
dbPath := filepath.Join(dataDir, "cpm.db")
// Create a dummy DB file to back up
err = os.WriteFile(dbPath, []byte("dummy db content"), 0644)
require.NoError(t, err)
cfg := &config.Config{
DatabasePath: dbPath,
}
svc := services.NewBackupService(cfg)
h := NewBackupHandler(svc)
r := gin.New()
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 os.RemoveAll(tmpDir)
// 1. List backups (should be empty)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
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", nil)
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", nil)
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", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 5. Download backup
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
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, nil)
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", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []interface{}
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", nil)
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", nil)
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", nil)
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 os.RemoveAll(tmpDir)
// 1. List Error (remove backup dir to cause ReadDir error)
os.RemoveAll(svc.BackupDir)
// Create a file with same name to cause ReadDir to fail (if it expects dir)
// Or just make it unreadable
// os.Chmod(svc.BackupDir, 0000) // Might not work as expected in all envs
// Simpler: if BackupDir doesn't exist, ListBackups returns error?
// os.ReadDir returns error if dir doesn't exist.
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// 2. Create Error (make backup dir read-only or non-existent)
// If we removed it above, CreateBackup might try to create it?
// NewBackupService creates it. CreateBackup uses it.
// If we create a file named "backups" where the dir should be, MkdirAll might fail?
// Or just make the parent dir read-only.
// Let's try path traversal for Download/Delete/Restore to cover those errors
// 3. Create Error (make backup dir read-only)
// We can't easily make the dir read-only for the service without affecting other tests or requiring root.
// But we can mock the service or use a different config.
// If we set BackupDir to a non-existent dir that cannot be created?
// NewBackupService creates it.
// If we set BackupDir to a file?
// Let's skip Create error for now and focus on what we can test.
// We can test Download Not Found (already covered).
// 4. Delete Error (Not Found)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}

View File

@@ -1,137 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
type CertificateHandler struct {
service *services.CertificateService
notificationService *services.NotificationService
}
func NewCertificateHandler(service *services.CertificateService, ns *services.NotificationService) *CertificateHandler {
return &CertificateHandler{
service: service,
notificationService: ns,
}
}
func (h *CertificateHandler) List(c *gin.Context) {
certs, err := h.service.ListCertificates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, certs)
}
type UploadCertificateRequest struct {
Name string `form:"name" binding:"required"`
Certificate string `form:"certificate"` // PEM content
PrivateKey string `form:"private_key"` // PEM content
}
func (h *CertificateHandler) Upload(c *gin.Context) {
// Handle multipart form
name := c.PostForm("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Read files
certFile, err := c.FormFile("certificate_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
return
}
// Open and read content
certSrc, err := certFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
return
}
defer certSrc.Close()
keySrc, err := keyFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
return
}
defer keySrc.Close()
// Read to string
// Limit size to avoid DoS (e.g. 1MB)
certBytes := make([]byte, 1024*1024)
n, _ := certSrc.Read(certBytes)
certPEM := string(certBytes[:n])
keyBytes := make([]byte, 1024*1024)
n, _ = keySrc.Read(keyBytes)
keyPEM := string(keyBytes[:n])
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"cert",
"Certificate Uploaded",
fmt.Sprintf("Certificate %s uploaded", cert.Name),
map[string]interface{}{
"Name": cert.Name,
"Domains": cert.Domains,
"Action": "uploaded",
},
)
}
c.JSON(http.StatusCreated, cert)
}
func (h *CertificateHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.DeleteCertificate(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
}

View File

@@ -1,168 +0,0 @@
package handlers
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func generateTestCert(t *testing.T, domain string) []byte {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: domain,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
}
func TestCertificateHandler_List(t *testing.T) {
// Setup temp dir
tmpDir := t.TempDir()
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
err := os.MkdirAll(caddyDir, 0755)
require.NoError(t, err)
// Setup in-memory DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/certificates", handler.List)
req, _ := http.NewRequest("GET", "/certificates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var certs []services.CertificateInfo
err = json.Unmarshal(w.Body.Bytes(), &certs)
assert.NoError(t, err)
assert.Empty(t, certs)
}
func TestCertificateHandler_Upload(t *testing.T) {
// Setup
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Prepare Multipart Request
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("name", "Test Cert")
certPEM := generateTestCert(t, "test.com")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
part.Write(certPEM)
part, _ = writer.CreateFormFile("key_file", "key.pem")
part.Write([]byte("FAKE KEY")) // Service doesn't validate key structure strictly yet, just PEM decoding?
// Actually service does: block, _ := pem.Decode([]byte(certPEM)) for cert.
// It doesn't seem to validate keyPEM in UploadCertificate, just stores it.
writer.Close()
req, _ := http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var cert models.SSLCertificate
err = json.Unmarshal(w.Body.Bytes(), &cert)
assert.NoError(t, err)
assert.Equal(t, "Test Cert", cert.Name)
}
func TestCertificateHandler_Delete(t *testing.T) {
// Setup
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
// Seed a cert
cert := models.SSLCertificate{
UUID: "test-uuid",
Name: "To Delete",
}
err = db.Create(&cert).Error
require.NoError(t, err)
require.NotZero(t, cert.ID)
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.DELETE("/certificates/:id", handler.Delete)
req, _ := http.NewRequest("DELETE", "/certificates/"+strconv.Itoa(int(cert.ID)), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify deletion
var deletedCert models.SSLCertificate
err = db.First(&deletedCert, cert.ID).Error
assert.Error(t, err)
assert.Equal(t, gorm.ErrRecordNotFound, err)
}

View File

@@ -1,31 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type DockerHandler struct {
dockerService *services.DockerService
}
func NewDockerHandler(dockerService *services.DockerService) *DockerHandler {
return &DockerHandler{dockerService: dockerService}
}
func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/docker/containers", h.ListContainers)
}
func (h *DockerHandler) ListContainers(c *gin.Context) {
host := c.Query("host")
containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
return
}
c.JSON(http.StatusOK, containers)
}

View File

@@ -1,40 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestDockerHandler_ListContainers(t *testing.T) {
// We can't easily mock the DockerService without an interface,
// and the DockerService depends on the real Docker client.
// So we'll just test that the handler is wired up correctly,
// even if it returns an error because Docker isn't running in the test env.
svc, _ := services.NewDockerService()
// svc might be nil if docker is not available, but NewDockerHandler handles nil?
// Actually NewDockerHandler just stores it.
// If svc is nil, ListContainers will panic.
// So we only run this if svc is not nil.
if svc == nil {
t.Skip("Docker not available")
}
h := NewDockerHandler(svc)
gin.SetMode(gin.TestMode)
r := gin.New()
h.RegisterRoutes(r.Group("/"))
req, _ := http.NewRequest("GET", "/docker/containers", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// It might return 200 or 500 depending on if ListContainers succeeds
assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
}

View File

@@ -1,92 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DomainHandler struct {
DB *gorm.DB
notificationService *services.NotificationService
}
func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
return &DomainHandler{
DB: db,
notificationService: ns,
}
}
func (h *DomainHandler) List(c *gin.Context) {
var domains []models.Domain
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
return
}
c.JSON(http.StatusOK, domains)
}
func (h *DomainHandler) Create(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
domain := models.Domain{
Name: input.Name,
}
if err := h.DB.Create(&domain).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"domain",
"Domain Added",
fmt.Sprintf("Domain %s added", domain.Name),
map[string]interface{}{
"Name": domain.Name,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, domain)
}
func (h *DomainHandler) Delete(c *gin.Context) {
id := c.Param("id")
var domain models.Domain
if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
// Send Notification before delete (or after if we keep the name)
if h.notificationService != nil {
h.notificationService.SendExternal(
"domain",
"Domain Deleted",
fmt.Sprintf("Domain %s deleted", domain.Name),
map[string]interface{}{
"Name": domain.Name,
"Action": "deleted",
},
)
}
}
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
}

View File

@@ -1,99 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Domain{}))
ns := services.NewNotificationService(db)
h := NewDomainHandler(db, ns)
r := gin.New()
// Manually register routes since DomainHandler doesn't have a RegisterRoutes method yet
// or we can just register them here for testing
r.GET("/api/v1/domains", h.List)
r.POST("/api/v1/domains", h.Create)
r.DELETE("/api/v1/domains/:id", h.Delete)
return r, db
}
func TestDomainLifecycle(t *testing.T) {
router, _ := setupDomainTestRouter(t)
// 1. Create Domain
body := `{"name":"example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.Domain
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "example.com", created.Name)
require.NotEmpty(t, created.UUID)
// 2. List Domains
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []models.Domain
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
require.Len(t, list, 1)
require.Equal(t, "example.com", list[0].Name)
// 3. Delete Domain
req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 4. Verify Deletion
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
require.Len(t, list, 0)
}
func TestDomainErrors(t *testing.T) {
router, _ := setupDomainTestRouter(t)
// 1. Create Invalid JSON
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{invalid}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// 2. Create Missing Name
req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}

View File

@@ -1,368 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
// Auto migrate
db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.RemoteServer{},
&models.ImportSession{},
)
return db
}
func TestRemoteServerHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var servers []models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &servers)
assert.NoError(t, err)
assert.Len(t, servers, 1)
assert.Equal(t, "Test Server", servers[0].Name)
}
func TestRemoteServerHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Create
serverData := map[string]interface{}{
"name": "New Server",
"provider": "generic",
"host": "192.168.1.100",
"port": 3000,
"enabled": true,
}
body, _ := json.Marshal(serverData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var server models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &server)
assert.NoError(t, err)
assert.Equal(t, "New Server", server.Name)
assert.NotEmpty(t, server.UUID)
}
func TestRemoteServerHandler_TestConnection(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 99999, // Invalid port to test failure
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test connection
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.False(t, result["reachable"].(bool))
assert.NotEmpty(t, result["error"])
}
func TestRemoteServerHandler_Get(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Get
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var fetched models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &fetched)
assert.NoError(t, err)
assert.Equal(t, server.UUID, fetched.UUID)
}
func TestRemoteServerHandler_Update(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Update
updateData := map[string]interface{}{
"name": "Updated Server",
"provider": "generic",
"host": "10.0.0.1",
"port": 9000,
"enabled": false,
}
body, _ := json.Marshal(updateData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/remote-servers/"+server.UUID, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &updated)
assert.NoError(t, err)
assert.Equal(t, "Updated Server", updated.Name)
assert.Equal(t, "generic", updated.Provider)
assert.False(t, updated.Enabled)
}
func TestRemoteServerHandler_Delete(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Delete
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Verify Delete
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusNotFound, w2.Code)
}
func TestProxyHostHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test proxy host
host := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Test Host",
DomainNames: "test.local",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 3000,
Enabled: true,
}
db.Create(host)
ns := services.NewNotificationService(db)
handler := handlers.NewProxyHostHandler(db, nil, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var hosts []models.ProxyHost
err := json.Unmarshal(w.Body.Bytes(), &hosts)
assert.NoError(t, err)
assert.Len(t, hosts, 1)
assert.Equal(t, "Test Host", hosts[0].Name)
}
func TestProxyHostHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewProxyHostHandler(db, nil, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Create
hostData := map[string]interface{}{
"name": "New Host",
"domain_names": "new.local",
"forward_scheme": "http",
"forward_host": "192.168.1.200",
"forward_port": 8080,
"enabled": true,
}
body, _ := json.Marshal(hostData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/proxy-hosts", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var host models.ProxyHost
err := json.Unmarshal(w.Body.Bytes(), &host)
assert.NoError(t, err)
assert.Equal(t, "New Host", host.Name)
assert.Equal(t, "new.local", host.DomainNames)
assert.NotEmpty(t, host.UUID)
}
func TestHealthHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health", handlers.HealthHandler)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]string
err := json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, "ok", result["status"])
}
func TestRemoteServerHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Get non-existent
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Update non-existent
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Delete non-existent
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -1,19 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
"github.com/gin-gonic/gin"
)
// HealthHandler responds with basic service metadata for uptime checks.
func HealthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": version.Name,
"version": version.Version,
"git_commit": version.GitCommit,
"build_time": version.BuildTime,
})
}

View File

@@ -1,29 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestHealthHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/health", HealthHandler)
req, _ := http.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, "ok", resp["status"])
assert.NotEmpty(t, resp["version"])
}

View File

@@ -1,421 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
// ImportHandler handles Caddyfile import operations.
type ImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
importerservice *caddy.Importer
importDir string
mountPath string
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
return &ImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
}
}
// RegisterRoutes registers import-related routes.
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/import/status", h.GetStatus)
router.GET("/import/preview", h.GetPreview)
router.POST("/import/upload", h.Upload)
router.POST("/import/commit", h.Commit)
router.DELETE("/import/cancel", h.Cancel)
}
// GetStatus returns current import session status.
func (h *ImportHandler) GetStatus(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"has_pending": false})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
},
})
}
// GetPreview returns parsed hosts and conflicts for review.
func (h *ImportHandler) GetPreview(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == nil {
// DB session found
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
// Update status to reviewing
session.Status = "reviewing"
h.db.Save(&session)
// Read original Caddyfile content if available
var caddyfileContent string
if session.SourceFile != "" {
if content, err := os.ReadFile(session.SourceFile); err == nil {
caddyfileContent = string(content)
} else {
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
if content, err := os.ReadFile(backupPath); err == nil {
caddyfileContent = string(content)
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
"source_file": session.SourceFile,
},
"preview": result,
"caddyfile_content": caddyfileContent,
})
return
}
}
// No DB session found or failed to parse session. Try transient preview from mountPath.
if h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
// Parse mounted Caddyfile transiently
transient, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
// Build a transient session id (not persisted)
sid := uuid.NewString()
var caddyfileContent string
if content, err := os.ReadFile(h.mountPath); err == nil {
caddyfileContent = string(content)
}
// Check for conflicts with existing hosts and append raw domain names
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts {
existingDomains[eh.DomainNames] = true
}
for _, ph := range transient.Hosts {
if existingDomains[ph.DomainNames] {
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
"preview": transient,
"caddyfile_content": caddyfileContent,
})
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
}
// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
Filename string `json:"filename"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
sid := uuid.NewString()
uploadsDir := filepath.Join(h.importDir, "uploads")
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
tempPath := filepath.Join(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}
// Parse uploaded file transiently
result, err := h.importerservice.ImportFile(tempPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
// Check for conflicts with existing hosts and append raw domain names
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts {
existingDomains[eh.DomainNames] = true
}
for _, ph := range result.Hosts {
if existingDomains[ph.DomainNames] {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
"preview": result,
})
}
// Commit finalizes the import with user's conflict resolutions.
func (h *ImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -> action (skip, rename, merge)
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try to find a DB-backed session first
var session models.ImportSession
var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "reviewing").First(&session).Error; err == nil {
// DB session found
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
return
}
} else {
// No DB session: check for uploaded temp file
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", req.SessionUUID))
if _, err := os.Stat(uploadsPath); err == nil {
r, err := h.importerservice.ImportFile(uploadsPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
return
}
result = r
// We'll create a committed DB session after applying
session = models.ImportSession{UUID: req.SessionUUID, SourceFile: uploadsPath}
} else if h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
r, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
result = r
session = models.ImportSession{UUID: req.SessionUUID, SourceFile: h.mountPath}
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
return
}
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
}
// Convert parsed hosts to ProxyHost models
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
log.Printf("Import Commit: Parsed %d hosts, converted to %d proxy hosts", len(result.Hosts), len(proxyHosts))
created := 0
skipped := 0
errors := []string{}
for _, host := range proxyHosts {
action := req.Resolutions[host.DomainNames]
if action == "skip" {
skipped++
continue
}
if action == "rename" {
host.DomainNames = host.DomainNames + "-imported"
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
log.Printf("Import Commit Error: %s", errMsg)
} else {
created++
log.Printf("Import Commit Success: Created host %s", host.DomainNames)
}
}
// Persist an import session record now that user confirmed
now := time.Now()
session.Status = "committed"
session.CommittedAt = &now
session.UserResolutions = string(mustMarshal(req.Resolutions))
// If ParsedData/ConflictReport not set, fill from result
if session.ParsedData == "" {
session.ParsedData = string(mustMarshal(result))
}
if session.ConflictReport == "" {
session.ConflictReport = string(mustMarshal(result.Conflicts))
}
if err := h.db.Save(&session).Error; err != nil {
log.Printf("Warning: failed to save import session: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"created": created,
"skipped": skipped,
"errors": errors,
})
}
// Cancel discards a pending import session.
func (h *ImportHandler) Cancel(c *gin.Context) {
sessionUUID := c.Query("session_uuid")
if sessionUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
var session models.ImportSession
if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err == nil {
session.Status = "rejected"
h.db.Save(&session)
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}
// If no DB session, check for uploaded temp file and delete it
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", sessionUUID))
if _, err := os.Stat(uploadsPath); err == nil {
os.Remove(uploadsPath)
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return
}
// If neither exists, return not found
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
}
// processImport handles the import logic for both mounted and uploaded files.
func (h *ImportHandler) processImport(caddyfilePath, originalName string) error {
// Validate Caddy binary
if err := h.importerservice.ValidateCaddyBinary(); err != nil {
return fmt.Errorf("caddy binary not available: %w", err)
}
// Parse and extract hosts
result, err := h.importerservice.ImportFile(caddyfilePath)
if err != nil {
return fmt.Errorf("import failed: %w", err)
}
// Check for conflicts with existing hosts
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, host := range existingHosts {
existingDomains[host.DomainNames] = true
}
for _, parsed := range result.Hosts {
if existingDomains[parsed.DomainNames] {
// Append the raw domain name so frontend can match conflicts against domain strings
result.Conflicts = append(result.Conflicts, parsed.DomainNames)
}
}
// Create import session
session := models.ImportSession{
UUID: uuid.NewString(),
SourceFile: originalName,
Status: "pending",
ParsedData: string(mustMarshal(result)),
ConflictReport: string(mustMarshal(result.Conflicts)),
}
if err := h.db.Create(&session).Error; err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
// Backup original file
if _, err := caddy.BackupCaddyfile(caddyfilePath, filepath.Join(h.importDir, "backups")); err != nil {
// Non-fatal, log and continue
fmt.Printf("Warning: failed to backup Caddyfile: %v\n", err)
}
return nil
}
// CheckMountedImport checks for mounted Caddyfile on startup.
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
return nil // No mounted file, nothing to import
}
// Check if already processed (includes committed to avoid re-imports)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
if count > 0 {
return nil // Already processed
}
// Do not create a DB session automatically for mounted imports; preview will be transient.
return nil
}
func mustMarshal(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}

View File

@@ -1,692 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
func setupImportTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{}, &models.Location{})
return db
}
func TestImportHandler_GetStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Case 1: No active session
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, false, resp["has_pending"])
// Case 2: Active session
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
}
db.Create(&session)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, true, resp["has_pending"])
}
func TestImportHandler_GetPreview(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Case 1: No session
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Case 2: Active session
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": [{"domain_names": "example.com"}]}`,
}
db.Create(&session)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
preview := result["preview"].(map[string]interface{})
hosts := preview["hosts"].([]interface{})
assert.Len(t, hosts, 1)
// Verify status changed to reviewing
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "reviewing", updatedSession.Status)
}
func TestImportHandler_Cancel(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
session := models.ImportSession{
UUID: "test-uuid",
Status: "pending",
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=test-uuid", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "rejected", updatedSession.Status)
}
func TestImportHandler_Commit(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
session := models.ImportSession{
UUID: "test-uuid",
Status: "reviewing",
ParsedData: `{"hosts": [{"domain_names": "example.com", "forward_host": "127.0.0.1", "forward_port": 8080}]}`,
}
db.Create(&session)
payload := map[string]interface{}{
"session_uuid": "test-uuid",
"resolutions": map[string]string{
"example.com": "import",
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err := db.Where("domain_names = ?", "example.com").First(&host).Error
assert.NoError(t, err)
assert.Equal(t, "127.0.0.1", host.ForwardHost)
// Verify session committed
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "committed", updatedSession.Status)
}
func TestImportHandler_Upload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "example.com",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
// The fake caddy script returns empty JSON, so import might fail or succeed with empty result
// But processImport calls ImportFile which calls ParseCaddyfile which calls caddy adapt
// fake_caddy.sh echoes `{"apps":{}}`
// ExtractHosts will return empty result
// processImport should succeed
assert.Equal(t, http.StatusOK, w.Code)
}
func TestImportHandler_GetPreview_WithContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Case: Active session with source file
content := "example.com {\n reverse_proxy localhost:8080\n}"
sourceFile := filepath.Join(tmpDir, "source.caddyfile")
err := os.WriteFile(sourceFile, []byte(content), 0644)
assert.NoError(t, err)
// Case: Active session with source file
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
SourceFile: sourceFile,
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_Commit_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
// Case 1: Invalid JSON
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid"))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Case 2: Session not found
payload := map[string]interface{}{
"session_uuid": "non-existent",
"resolutions": map[string]string{},
}
body, _ := json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Case 3: Invalid ParsedData
session := models.ImportSession{
UUID: "invalid-data-uuid",
Status: "reviewing",
ParsedData: "invalid-json",
}
db.Create(&session)
payload = map[string]interface{}{
"session_uuid": "invalid-data-uuid",
"resolutions": map[string]string{},
}
body, _ = json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestImportHandler_Cancel_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
// Case 1: Session not found
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestCheckMountedImport(t *testing.T) {
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
// Case 1: File does not exist
err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Case 2: File exists, not processed
err = os.WriteFile(mountPath, []byte("example.com"), 0644)
assert.NoError(t, err)
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Check if session created (transient preview behavior: no DB session should be created)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ?", mountPath).Count(&count)
assert.Equal(t, int64(0), count)
// Case 3: Already processed
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
}
func TestImportHandler_Upload_Failure(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Use fake caddy script that fails
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_fail.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "invalid caddyfile",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// The error message comes from processImport -> ImportFile -> "import failed: ..."
assert.Contains(t, resp["error"], "import failed")
}
func TestImportHandler_Upload_Conflict(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Pre-create a host to cause conflict
db.Create(&models.ProxyHost{
DomainNames: "example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 9090,
})
// Use fake caddy script that returns hosts
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "example.com",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify response contains conflict in preview (upload is transient)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
preview := resp["preview"].(map[string]interface{})
conflicts := preview["conflicts"].([]interface{})
found := false
for _, c := range conflicts {
if c.(string) == "example.com" || strings.Contains(c.(string), "example.com") {
found = true
break
}
}
assert.True(t, found, "expected conflict for example.com in preview")
}
func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Create backup file
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0755)
content := "backup content"
backupFile := filepath.Join(backupDir, "source.caddyfile")
os.WriteFile(backupFile, []byte(content), 0644)
// Case: Active session with missing source file but existing backup
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
SourceFile: "/non/existent/source.caddyfile",
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_RegisterRoutes(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
// Verify routes exist by making requests
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/import/status", nil)
router.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
func TestImportHandler_GetPreview_TransientMount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Create a mounted Caddyfile
content := "example.com"
err := os.WriteFile(mountPath, []byte(content), 0644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Response body: %s", w.Body.String())
var result map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
// Verify transient session
session, ok := result["session"].(map[string]interface{})
assert.True(t, ok, "session should be present in response")
assert.Equal(t, "transient", session["state"])
assert.Equal(t, mountPath, session["source_file"])
// Verify preview contains hosts
preview, ok := result["preview"].(map[string]interface{})
assert.True(t, ok, "preview should be present in response")
assert.NotNil(t, preview["hosts"])
// Verify content
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_Commit_TransientUpload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
// First upload to create transient session
uploadPayload := map[string]string{
"content": "uploaded.com",
"filename": "Caddyfile",
}
uploadBody, _ := json.Marshal(uploadPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Extract session ID
var uploadResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &uploadResp)
session := uploadResp["session"].(map[string]interface{})
sessionID := session["id"].(string)
// Now commit the transient upload
commitPayload := map[string]interface{}{
"session_uuid": sessionID,
"resolutions": map[string]string{
"uploaded.com": "import",
},
}
commitBody, _ := json.Marshal(commitPayload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err := db.Where("domain_names = ?", "uploaded.com").First(&host).Error
assert.NoError(t, err)
assert.Equal(t, "uploaded.com", host.DomainNames)
// Verify session persisted
var importSession models.ImportSession
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
assert.NoError(t, err)
assert.Equal(t, "committed", importSession.Status)
}
func TestImportHandler_Commit_TransientMount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Create a mounted Caddyfile
err := os.WriteFile(mountPath, []byte("mounted.com"), 0644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
router.POST("/import/commit", handler.Commit)
// Commit the mount with a random session ID (transient)
sessionID := uuid.NewString()
commitPayload := map[string]interface{}{
"session_uuid": sessionID,
"resolutions": map[string]string{
"mounted.com": "import",
},
}
commitBody, _ := json.Marshal(commitPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err = db.Where("domain_names = ?", "mounted.com").First(&host).Error
assert.NoError(t, err)
// Verify session persisted
var importSession models.ImportSession
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
assert.NoError(t, err)
assert.Equal(t, "committed", importSession.Status)
}
func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.DELETE("/import/cancel", handler.Cancel)
// Upload to create transient file
uploadPayload := map[string]string{
"content": "test.com",
"filename": "Caddyfile",
}
uploadBody, _ := json.Marshal(uploadPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Extract session ID and file path
var uploadResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &uploadResp)
session := uploadResp["session"].(map[string]interface{})
sessionID := session["id"].(string)
sourceFile := session["source_file"].(string)
// Verify file exists
_, err := os.Stat(sourceFile)
assert.NoError(t, err)
// Cancel should delete the file
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify file deleted
_, err = os.Stat(sourceFile)
assert.True(t, os.IsNotExist(err))
}
func TestImportHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
router.DELETE("/import/cancel", handler.Cancel)
// Upload - Invalid JSON
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Commit - Invalid JSON
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Commit - Session Not Found
body := map[string]interface{}{
"session_uuid": "non-existent",
"resolutions": map[string]string{},
}
jsonBody, _ := json.Marshal(body)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Cancel - Session Not Found
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -1,106 +0,0 @@
package handlers
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type LogsHandler struct {
service *services.LogService
}
func NewLogsHandler(service *services.LogService) *LogsHandler {
return &LogsHandler{service: service}
}
func (h *LogsHandler) List(c *gin.Context) {
logs, err := h.service.ListLogs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
return
}
c.JSON(http.StatusOK, logs)
}
func (h *LogsHandler) Read(c *gin.Context) {
filename := c.Param("filename")
// Parse query parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
filter := models.LogFilter{
Search: c.Query("search"),
Host: c.Query("host"),
Status: c.Query("status"),
Level: c.Query("level"),
Limit: limit,
Offset: offset,
Sort: c.DefaultQuery("sort", "desc"),
}
logs, total, err := h.service.QueryLogs(filename, filter)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
return
}
c.JSON(http.StatusOK, gin.H{
"filename": filename,
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *LogsHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetLogPath(filename)
if err != nil {
if strings.Contains(err.Error(), "invalid filename") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
// Create a temporary file to serve a consistent snapshot
// This prevents Content-Length mismatches if the live log file grows during download
tmpFile, err := os.CreateTemp("", "cpmp-log-*.log")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
return
}
defer os.Remove(tmpFile.Name())
srcFile, err := os.Open(path)
if err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
return
}
defer srcFile.Close()
if _, err := io.Copy(tmpFile, srcFile); err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
return
}
tmpFile.Close()
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(tmpFile.Name())
}

View File

@@ -1,136 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
t.Helper()
// Create temp directories
tmpDir, err := os.MkdirTemp("", "cpm-logs-test")
require.NoError(t, err)
// LogService expects LogDir to be .../data/logs
// It derives it from cfg.DatabasePath
dataDir := filepath.Join(tmpDir, "data")
err = os.MkdirAll(dataDir, 0755)
require.NoError(t, err)
dbPath := filepath.Join(dataDir, "cpm.db")
// Create logs dir
logsDir := filepath.Join(dataDir, "logs")
err = os.MkdirAll(logsDir, 0755)
require.NoError(t, err)
// Create dummy log files with JSON content
log1 := `{"level":"info","ts":1600000000,"msg":"request handled","request":{"method":"GET","host":"example.com","uri":"/","remote_ip":"1.2.3.4"},"status":200}`
log2 := `{"level":"error","ts":1600000060,"msg":"error handled","request":{"method":"POST","host":"api.example.com","uri":"/submit","remote_ip":"5.6.7.8"},"status":500}`
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(logsDir, "cpmp.log"), []byte("app log line 1\napp log line 2"), 0644)
require.NoError(t, err)
cfg := &config.Config{
DatabasePath: dbPath,
}
svc := services.NewLogService(cfg)
h := NewLogsHandler(svc)
r := gin.New()
api := r.Group("/api/v1")
logs := api.Group("/logs")
logs.GET("", h.List)
logs.GET("/:filename", h.Read)
logs.GET("/:filename/download", h.Download)
return r, svc, tmpDir
}
func TestLogsLifecycle(t *testing.T) {
router, _, tmpDir := setupLogsTest(t)
defer os.RemoveAll(tmpDir)
// 1. List logs
req := httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var logs []services.LogFile
err := json.Unmarshal(resp.Body.Bytes(), &logs)
require.NoError(t, err)
require.Len(t, logs, 2) // access.log and cpmp.log
// Verify content of one log file
found := false
for _, l := range logs {
if l.Name == "access.log" {
found = true
require.Greater(t, l.Size, int64(0))
}
}
require.True(t, found)
// 2. Read log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var content struct {
Filename string `json:"filename"`
Logs []interface{} `json:"logs"`
Total int `json:"total"`
}
err = json.Unmarshal(resp.Body.Bytes(), &content)
require.NoError(t, err)
require.Len(t, content.Logs, 2)
// 3. Download log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.Contains(t, resp.Body.String(), "request handled")
// 4. Read non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 5. Download non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 6. List logs error (delete directory)
os.RemoveAll(filepath.Join(tmpDir, "data", "logs"))
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// ListLogs returns empty list if dir doesn't exist, so it should be 200 OK with empty list
require.Equal(t, http.StatusOK, resp.Code)
var emptyLogs []services.LogFile
err = json.Unmarshal(resp.Body.Bytes(), &emptyLogs)
require.NoError(t, err)
require.Empty(t, emptyLogs)
}

View File

@@ -1,43 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *services.NotificationService
}
func NewNotificationHandler(service *services.NotificationService) *NotificationHandler {
return &NotificationHandler{service: service}
}
func (h *NotificationHandler) List(c *gin.Context) {
unreadOnly := c.Query("unread") == "true"
notifications, err := h.service.List(unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
return
}
c.JSON(http.StatusOK, notifications)
}
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
id := c.Param("id")
if err := h.service.MarkAsRead(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
}
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
if err := h.service.MarkAllAsRead(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
}

View File

@@ -1,148 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupNotificationTestDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.Notification{})
return db
}
func TestNotificationHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true})
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.GET("/notifications", handler.List)
// Test List All
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/notifications", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var notifications []models.Notification
err := json.Unmarshal(w.Body.Bytes(), &notifications)
assert.NoError(t, err)
assert.Len(t, notifications, 2)
// Test List Unread
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notifications?unread=true", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &notifications)
assert.NoError(t, err)
assert.Len(t, notifications, 1)
assert.False(t, notifications[0].Read)
}
func TestNotificationHandler_MarkAsRead(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}
db.Create(notif)
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/:id/read", handler.MarkAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/"+notif.ID+"/read", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.Notification
db.First(&updated, "id = ?", notif.ID)
assert.True(t, updated.Read)
}
func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false})
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/read-all", handler.MarkAllAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var count int64
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
r := gin.New()
r.POST("/notifications/read-all", handler.MarkAllAsRead)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestNotificationHandler_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
r := gin.New()
r.POST("/notifications/:id/read", handler.MarkAsRead)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/1/read", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}

View File

@@ -1,82 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationProviderHandler struct {
service *services.NotificationService
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
return &NotificationProviderHandler{service: service}
}
func (h *NotificationProviderHandler) List(c *gin.Context) {
providers, err := h.service.ListProviders()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
return
}
c.JSON(http.StatusOK, providers)
}
func (h *NotificationProviderHandler) Create(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateProvider(&provider); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return
}
c.JSON(http.StatusCreated, provider)
}
func (h *NotificationProviderHandler) Update(c *gin.Context) {
id := c.Param("id")
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
provider.ID = id
if err := h.service.UpdateProvider(&provider); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return
}
c.JSON(http.StatusOK, provider)
}
func (h *NotificationProviderHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteProvider(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})
}
func (h *NotificationProviderHandler) Test(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestProvider(provider); err != nil {
// Create internal notification for the failure
h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}

View File

@@ -1,144 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := services.NewNotificationService(db)
handler := handlers.NewNotificationProviderHandler(service)
r := gin.Default()
api := r.Group("/api/v1")
providers := api.Group("/notification-providers")
providers.GET("", handler.List)
providers.POST("", handler.Create)
providers.PUT("/:id", handler.Update)
providers.DELETE("/:id", handler.Delete)
providers.POST("/test", handler.Test)
return r, db
}
func TestNotificationProviderHandler_CRUD(t *testing.T) {
r, db := setupNotificationProviderTest(t)
// 1. Create
provider := models.NotificationProvider{
Name: "Test Discord",
Type: "discord",
URL: "https://discord.com/api/webhooks/...",
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var created models.NotificationProvider
err := json.Unmarshal(w.Body.Bytes(), &created)
require.NoError(t, err)
assert.Equal(t, provider.Name, created.Name)
assert.NotEmpty(t, created.ID)
// 2. List
req, _ = http.NewRequest("GET", "/api/v1/notification-providers", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var list []models.NotificationProvider
err = json.Unmarshal(w.Body.Bytes(), &list)
require.NoError(t, err)
assert.Len(t, list, 1)
// 3. Update
created.Name = "Updated Discord"
body, _ = json.Marshal(created)
req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/"+created.ID, bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.NotificationProvider
err = json.Unmarshal(w.Body.Bytes(), &updated)
require.NoError(t, err)
assert.Equal(t, "Updated Discord", updated.Name)
// Verify in DB
var dbProvider models.NotificationProvider
db.First(&dbProvider, "id = ?", created.ID)
assert.Equal(t, "Updated Discord", dbProvider.Name)
// 4. Delete
req, _ = http.NewRequest("DELETE", "/api/v1/notification-providers/"+created.ID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify Delete
var count int64
db.Model(&models.NotificationProvider{}).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestNotificationProviderHandler_Test(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Test with invalid provider (should fail validation or service check)
// Since we don't have a real shoutrrr backend mocked easily here without more work,
// we expect it might fail or pass depending on service implementation.
// Looking at service code (not shown but assumed), TestProvider likely calls shoutrrr.Send.
// If URL is invalid, it should error.
provider := models.NotificationProvider{
Type: "discord",
URL: "invalid-url",
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// It should probably fail with 400
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNotificationProviderHandler_Errors(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Create Invalid JSON
req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer([]byte("invalid")))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Update Invalid JSON
req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/123", bytes.NewBuffer([]byte("invalid")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Test Invalid JSON
req, _ = http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer([]byte("invalid")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}

View File

@@ -1,201 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
// ProxyHostHandler handles CRUD operations for proxy hosts.
type ProxyHostHandler struct {
service *services.ProxyHostService
caddyManager *caddy.Manager
notificationService *services.NotificationService
}
// NewProxyHostHandler creates a new proxy host handler.
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService) *ProxyHostHandler {
return &ProxyHostHandler{
service: services.NewProxyHostService(db),
caddyManager: caddyManager,
notificationService: ns,
}
}
// RegisterRoutes registers proxy host routes.
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/proxy-hosts", h.List)
router.POST("/proxy-hosts", h.Create)
router.GET("/proxy-hosts/:uuid", h.Get)
router.PUT("/proxy-hosts/:uuid", h.Update)
router.DELETE("/proxy-hosts/:uuid", h.Delete)
router.POST("/proxy-hosts/test", h.TestConnection)
}
// List retrieves all proxy hosts.
func (h *ProxyHostHandler) List(c *gin.Context) {
hosts, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hosts)
}
// Create creates a new proxy host.
func (h *ProxyHostHandler) Create(c *gin.Context) {
var host models.ProxyHost
if err := c.ShouldBindJSON(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
host.UUID = uuid.NewString()
// Assign UUIDs to locations
for i := range host.Locations {
host.Locations[i].UUID = uuid.NewString()
}
if err := h.service.Create(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
// Rollback: delete the created host if config application fails
fmt.Printf("Error applying config: %v\n", err) // Log to stdout
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
fmt.Printf("Critical: Failed to rollback host %d: %v\n", host.ID, deleteErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"proxy_host",
"Proxy Host Created",
fmt.Sprintf("Proxy Host %s (%s) created", host.Name, host.DomainNames),
map[string]interface{}{
"Name": host.Name,
"Domains": host.DomainNames,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, host)
}
// Get retrieves a proxy host by UUID.
func (h *ProxyHostHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
c.JSON(http.StatusOK, host)
}
// Update updates an existing proxy host.
func (h *ProxyHostHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
if err := c.ShouldBindJSON(host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, host)
}
// Delete removes a proxy host.
func (h *ProxyHostHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
if err := h.service.Delete(host.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"proxy_host",
"Proxy Host Deleted",
fmt.Sprintf("Proxy Host %s deleted", host.Name),
map[string]interface{}{
"Name": host.Name,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})
}
// TestConnection checks if the proxy host is reachable.
func (h *ProxyHostHandler) TestConnection(c *gin.Context) {
var req struct {
ForwardHost string `json:"forward_host" binding:"required"`
ForwardPort int `json:"forward_port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})
}

View File

@@ -1,338 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, nil, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
return r, db
}
func TestProxyHostLifecycle(t *testing.T) {
router, _ := setupTestRouter(t)
body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "media.example.com", created.DomainNames)
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
listResp := httptest.NewRecorder()
router.ServeHTTP(listResp, listReq)
require.Equal(t, http.StatusOK, listResp.Code)
var hosts []models.ProxyHost
require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &hosts))
require.Len(t, hosts, 1)
// Get by ID
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getResp := httptest.NewRecorder()
router.ServeHTTP(getResp, getReq)
require.Equal(t, http.StatusOK, getResp.Code)
var fetched models.ProxyHost
require.NoError(t, json.Unmarshal(getResp.Body.Bytes(), &fetched))
require.Equal(t, created.UUID, fetched.UUID)
// Update
updateBody := `{"name":"Media Updated","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":false}`
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+created.UUID, strings.NewReader(updateBody))
updateReq.Header.Set("Content-Type", "application/json")
updateResp := httptest.NewRecorder()
router.ServeHTTP(updateResp, updateReq)
require.Equal(t, http.StatusOK, updateResp.Code)
var updated models.ProxyHost
require.NoError(t, json.Unmarshal(updateResp.Body.Bytes(), &updated))
require.Equal(t, "Media Updated", updated.Name)
require.False(t, updated.Enabled)
// Delete
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, nil)
delResp := httptest.NewRecorder()
router.ServeHTTP(delResp, delReq)
require.Equal(t, http.StatusOK, delResp.Code)
// Verify Delete
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getResp2 := httptest.NewRecorder()
router.ServeHTTP(getResp2, getReq2)
require.Equal(t, http.StatusNotFound, getResp2.Code)
}
func TestProxyHostErrors(t *testing.T) {
// Mock Caddy Admin API that fails
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer caddyServer.Close()
// Setup DB
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "")
// Setup Handler
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, manager, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
// Test Create - Bind Error
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test Create - Apply Config Error
body := `{"name":"Fail Host","domain_names":"fail-unique-456.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Create a host for Update/Delete/Get tests (manually in DB to avoid handler error)
host := models.ProxyHost{
UUID: uuid.NewString(),
Name: "Existing Host",
DomainNames: "exist.local",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8080,
Enabled: true,
}
db.Create(&host)
// Test Get - Not Found
req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Update - Not Found
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Update - Bind Error
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test Update - Apply Config Error
updateBody := `{"name":"Fail Host Update","domain_names":"fail-unique-update.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Test Delete - Not Found
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Delete - Apply Config Error
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Test TestConnection - Bind Error
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test TestConnection - Connection Failure
testBody := `{"forward_host": "invalid.host.local", "forward_port": 12345}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(testBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadGateway, resp.Code)
}
func TestProxyHostValidation(t *testing.T) {
router, db := setupTestRouter(t)
// Invalid JSON
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{invalid json}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Create a host first
host := &models.ProxyHost{
UUID: "valid-uuid",
DomainNames: "valid.com",
}
db.Create(host)
// Update with invalid JSON
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/valid-uuid", strings.NewReader(`{invalid json}`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
func TestProxyHostConnection(t *testing.T) {
router, _ := setupTestRouter(t)
// 1. Test Invalid Input (Missing Host)
body := `{"forward_port": 80}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// 2. Test Connection Failure (Unreachable Port)
// Use a reserved port or localhost port that is likely closed
body = `{"forward_host": "localhost", "forward_port": 54321}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// It should return 502 Bad Gateway
require.Equal(t, http.StatusBadGateway, resp.Code)
// 3. Test Connection Success
// Start a local listener
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
addr := l.Addr().(*net.TCPAddr)
body = fmt.Sprintf(`{"forward_host": "%s", "forward_port": %d}`, addr.IP.String(), addr.Port)
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
func TestProxyHostHandler_List_Error(t *testing.T) {
router, db := setupTestRouter(t)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
}
func TestProxyHostWithCaddyIntegration(t *testing.T) {
// Mock Caddy Admin API
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == "POST" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
// Setup DB
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "")
// Setup Handler
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, manager, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
// Test Create with Caddy Sync
body := `{"name":"Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
// Test Update with Caddy Sync
var createdHost models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &createdHost))
updateBody := `{"name":"Updated Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8081,"enabled":true}`
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+createdHost.UUID, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Test Delete with Caddy Sync
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}

View File

@@ -1,238 +0,0 @@
package handlers
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
// RemoteServerHandler handles HTTP requests for remote server management.
type RemoteServerHandler struct {
service *services.RemoteServerService
notificationService *services.NotificationService
}
// NewRemoteServerHandler creates a new remote server handler.
func NewRemoteServerHandler(db *gorm.DB, ns *services.NotificationService) *RemoteServerHandler {
return &RemoteServerHandler{
service: services.NewRemoteServerService(db),
notificationService: ns,
}
}
// RegisterRoutes registers remote server routes.
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/remote-servers", h.List)
router.POST("/remote-servers", h.Create)
router.GET("/remote-servers/:uuid", h.Get)
router.PUT("/remote-servers/:uuid", h.Update)
router.DELETE("/remote-servers/:uuid", h.Delete)
router.POST("/remote-servers/test", h.TestConnectionCustom)
router.POST("/remote-servers/:uuid/test", h.TestConnection)
}
// List retrieves all remote servers.
func (h *RemoteServerHandler) List(c *gin.Context) {
enabledOnly := c.Query("enabled") == "true"
servers, err := h.service.List(enabledOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, servers)
}
// Create creates a new remote server.
func (h *RemoteServerHandler) Create(c *gin.Context) {
var server models.RemoteServer
if err := c.ShouldBindJSON(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server.UUID = uuid.NewString()
if err := h.service.Create(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"remote_server",
"Remote Server Added",
fmt.Sprintf("Remote Server %s (%s:%d) added", server.Name, server.Host, server.Port),
map[string]interface{}{
"Name": server.Name,
"Host": server.Host,
"Port": server.Port,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, server)
}
// Get retrieves a remote server by UUID.
func (h *RemoteServerHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.JSON(http.StatusOK, server)
}
// Update updates an existing remote server.
func (h *RemoteServerHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := c.ShouldBindJSON(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, server)
}
// Delete removes a remote server.
func (h *RemoteServerHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := h.service.Delete(server.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"remote_server",
"Remote Server Deleted",
fmt.Sprintf("Remote Server %s deleted", server.Name),
map[string]interface{}{
"Name": server.Name,
"Action": "deleted",
},
)
}
c.JSON(http.StatusNoContent, nil)
}
// TestConnection tests the TCP connection to a remote server.
func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port))
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"server_uuid": server.UUID,
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
// Update server reachability status
server.Reachable = false
now := time.Now().UTC()
server.LastChecked = &now
h.service.Update(server)
c.JSON(http.StatusOK, result)
return
}
defer conn.Close()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(time.Now()).Milliseconds()
// Update server reachability status
server.Reachable = true
now := time.Now().UTC()
server.LastChecked = &now
h.service.Update(server)
c.JSON(http.StatusOK, result)
}
// TestConnectionCustom tests connectivity to a host/port provided in the body
func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
var req struct {
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
start := time.Now()
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
c.JSON(http.StatusOK, result)
return
}
defer conn.Close()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(start).Milliseconds()
c.JSON(http.StatusOK, result)
}

View File

@@ -1,129 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServerHandler) {
t.Helper()
db := setupTestDB()
// Ensure RemoteServer table exists
db.AutoMigrate(&models.RemoteServer{})
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(db, ns)
r := gin.Default()
api := r.Group("/api/v1")
servers := api.Group("/remote-servers")
servers.GET("", handler.List)
servers.POST("", handler.Create)
servers.GET("/:uuid", handler.Get)
servers.PUT("/:uuid", handler.Update)
servers.DELETE("/:uuid", handler.Delete)
servers.POST("/test", handler.TestConnectionCustom)
servers.POST("/:uuid/test", handler.TestConnection)
return r, handler
}
func TestRemoteServerHandler_TestConnectionCustom(t *testing.T) {
r, _ := setupRemoteServerTest_New(t)
// Test with a likely closed port
payload := map[string]interface{}{
"host": "127.0.0.1",
"port": 54321,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, false, result["reachable"])
assert.NotEmpty(t, result["error"])
}
func TestRemoteServerHandler_FullCRUD(t *testing.T) {
r, _ := setupRemoteServerTest_New(t)
// Create
rs := models.RemoteServer{
Name: "Test Server CRUD",
Host: "192.168.1.100",
Port: 22,
Provider: "manual",
}
body, _ := json.Marshal(rs)
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var created models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &created)
require.NoError(t, err)
assert.Equal(t, rs.Name, created.Name)
assert.NotEmpty(t, created.UUID)
// List
req, _ = http.NewRequest("GET", "/api/v1/remote-servers", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Get
req, _ = http.NewRequest("GET", "/api/v1/remote-servers/"+created.UUID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Update
created.Name = "Updated Server CRUD"
body, _ = json.Marshal(created)
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/"+created.UUID, bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Delete
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/"+created.UUID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Create - Invalid JSON
req, _ = http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer([]byte("invalid json")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Update - Not Found
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent-uuid", bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Delete - Not Found
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent-uuid", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -1,71 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
type SettingsHandler struct {
DB *gorm.DB
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
return &SettingsHandler{DB: db}
}
// GetSettings returns all settings.
func (h *SettingsHandler) GetSettings(c *gin.Context) {
var settings []models.Setting
if err := h.DB.Find(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
// Convert to map for easier frontend consumption
settingsMap := make(map[string]string)
for _, s := range settings {
settingsMap[s.Key] = s.Value
}
c.JSON(http.StatusOK, settingsMap)
}
type UpdateSettingRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Category string `json:"category"`
Type string `json:"type"`
}
// UpdateSetting updates or creates a setting.
func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
var req UpdateSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
setting := models.Setting{
Key: req.Key,
Value: req.Value,
}
if req.Category != "" {
setting.Category = req.Category
}
if req.Type != "" {
setting.Type = req.Type
}
// Upsert
if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
return
}
c.JSON(http.StatusOK, setting)
}

View File

@@ -1,121 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
func setupSettingsTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.Setting{})
return db
}
func TestSettingsHandler_GetSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
// Seed data
db.Create(&models.Setting{Key: "test_key", Value: "test_value", Category: "general", Type: "string"})
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]string
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "test_value", response["test_key"])
}
func TestSettingsHandler_UpdateSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.POST("/settings", handler.UpdateSetting)
// Test Create
payload := map[string]string{
"key": "new_key",
"value": "new_value",
"category": "system",
"type": "string",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "new_value", setting.Value)
// Test Update
payload["value"] = "updated_value"
body, _ = json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "updated_value", setting.Value)
}
func TestSettingsHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.POST("/settings", handler.UpdateSetting)
// Invalid JSON
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Missing Key/Value
payload := map[string]string{
"key": "some_key",
// value missing
}
body, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}

View File

@@ -1,2 +0,0 @@
#!/bin/sh
echo '{"apps":{}}'

View File

@@ -1,6 +0,0 @@
#!/bin/sh
if [ "$1" = "version" ]; then
echo "v2.0.0"
exit 0
fi
exit 1

View File

@@ -1,15 +0,0 @@
#!/bin/sh
if [ "$1" = "version" ]; then
echo "v2.0.0"
exit 0
fi
if [ "$1" = "adapt" ]; then
# Read the domain from the input Caddyfile (stdin or --config file)
DOMAIN="example.com"
if [ "$2" = "--config" ]; then
DOMAIN=$(cat "$3" | head -1 | tr -d '\n')
fi
echo "{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"$DOMAIN\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"localhost:8080\"}]}]}]}}}}}"
exit 0
fi
exit 1

View File

@@ -1,25 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UpdateHandler struct {
service *services.UpdateService
}
func NewUpdateHandler(service *services.UpdateService) *UpdateHandler {
return &UpdateHandler{service: service}
}
func (h *UpdateHandler) Check(c *gin.Context) {
info, err := h.service.CheckForUpdates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
return
}
c.JSON(http.StatusOK, info)
}

View File

@@ -1,90 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func TestUpdateHandler_Check(t *testing.T) {
// Mock GitHub API
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/releases/latest" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"tag_name":"v1.0.0","html_url":"https://github.com/example/repo/releases/tag/v1.0.0"}`))
}))
defer server.Close()
// Setup Service
svc := services.NewUpdateService()
svc.SetAPIURL(server.URL + "/releases/latest")
// Setup Handler
h := NewUpdateHandler(svc)
// Setup Router
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/v1/update", h.Check)
// Test Request
req := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
var info services.UpdateInfo
err := json.Unmarshal(resp.Body.Bytes(), &info)
assert.NoError(t, err)
assert.True(t, info.Available) // Assuming current version is not v1.0.0
assert.Equal(t, "v1.0.0", info.LatestVersion)
// Test Failure
serverError := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer serverError.Close()
svcError := services.NewUpdateService()
svcError.SetAPIURL(serverError.URL)
hError := NewUpdateHandler(svcError)
rError := gin.New()
rError.GET("/api/v1/update", hError.Check)
reqError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
respError := httptest.NewRecorder()
rError.ServeHTTP(respError, reqError)
assert.Equal(t, http.StatusOK, respError.Code)
var infoError services.UpdateInfo
err = json.Unmarshal(respError.Body.Bytes(), &infoError)
assert.NoError(t, err)
assert.False(t, infoError.Available)
// Test Client Error (Invalid URL)
svcClientError := services.NewUpdateService()
svcClientError.SetAPIURL("http://invalid-url-that-does-not-exist")
hClientError := NewUpdateHandler(svcClientError)
rClientError := gin.New()
rClientError.GET("/api/v1/update", hClientError.Check)
reqClientError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
respClientError := httptest.NewRecorder()
rClientError.ServeHTTP(respClientError, reqClientError)
// CheckForUpdates returns error on client failure
// Handler returns 500 on error
assert.Equal(t, http.StatusInternalServerError, respClientError.Code)
}

View File

@@ -1,38 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UptimeHandler struct {
service *services.UptimeService
}
func NewUptimeHandler(service *services.UptimeService) *UptimeHandler {
return &UptimeHandler{service: service}
}
func (h *UptimeHandler) List(c *gin.Context) {
monitors, err := h.service.ListMonitors()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"})
return
}
c.JSON(http.StatusOK, monitors)
}
func (h *UptimeHandler) GetHistory(c *gin.Context) {
id := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
history, err := h.service.GetMonitorHistory(id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
return
}
c.JSON(http.StatusOK, history)
}

View File

@@ -1,99 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.NotificationProvider{}, &models.Notification{}))
ns := services.NewNotificationService(db)
service := services.NewUptimeService(db, ns)
handler := handlers.NewUptimeHandler(service)
r := gin.Default()
api := r.Group("/api/v1")
uptime := api.Group("/uptime")
uptime.GET("", handler.List)
uptime.GET("/:id/history", handler.GetHistory)
return r, db
}
func TestUptimeHandler_List(t *testing.T) {
r, db := setupUptimeHandlerTest(t)
// Seed Monitor
monitor := models.UptimeMonitor{
ID: "monitor-1",
Name: "Test Monitor",
Type: "http",
URL: "http://example.com",
}
db.Create(&monitor)
req, _ := http.NewRequest("GET", "/api/v1/uptime", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var list []models.UptimeMonitor
err := json.Unmarshal(w.Body.Bytes(), &list)
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "Test Monitor", list[0].Name)
}
func TestUptimeHandler_GetHistory(t *testing.T) {
r, db := setupUptimeHandlerTest(t)
// Seed Monitor and Heartbeats
monitorID := "monitor-1"
monitor := models.UptimeMonitor{
ID: monitorID,
Name: "Test Monitor",
}
db.Create(&monitor)
db.Create(&models.UptimeHeartbeat{
MonitorID: monitorID,
Status: "up",
Latency: 10,
CreatedAt: time.Now().Add(-1 * time.Minute),
})
db.Create(&models.UptimeHeartbeat{
MonitorID: monitorID,
Status: "down",
Latency: 0,
CreatedAt: time.Now(),
})
req, _ := http.NewRequest("GET", "/api/v1/uptime/"+monitorID+"/history", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var history []models.UptimeHeartbeat
err := json.Unmarshal(w.Body.Bytes(), &history)
require.NoError(t, err)
assert.Len(t, history, 2)
// Should be ordered by created_at desc
assert.Equal(t, "down", history[0].Status)
}

View File

@@ -1,222 +0,0 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
type UserHandler struct {
DB *gorm.DB
}
func NewUserHandler(db *gorm.DB) *UserHandler {
return &UserHandler{DB: db}
}
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/setup", h.GetSetupStatus)
r.POST("/setup", h.Setup)
r.GET("/profile", h.GetProfile)
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
r.PUT("/profile", h.UpdateProfile)
}
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
func (h *UserHandler) GetSetupStatus(c *gin.Context) {
var count int64
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}
c.JSON(http.StatusOK, gin.H{
"setupRequired": count == 0,
})
}
type SetupRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// Setup creates the initial admin user and configures the ACME email.
func (h *UserHandler) Setup(c *gin.Context) {
// 1. Check if setup is allowed
var count int64
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}
if count > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
return
}
// 2. Parse request
var req SetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 3. Create User
user := models.User{
UUID: uuid.New().String(),
Name: req.Name,
Email: strings.ToLower(req.Email),
Role: "admin",
Enabled: true,
APIKey: uuid.New().String(),
}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// 4. Create Setting for ACME Email
acmeEmailSetting := models.Setting{
Key: "caddy.acme_email",
Value: req.Email,
Type: "string",
Category: "caddy",
}
// Transaction to ensure both succeed
err := h.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
// Use Save to update if exists (though it shouldn't in fresh setup) or create
if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil {
return err
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Setup completed successfully",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
})
}
// RegenerateAPIKey generates a new API key for the authenticated user.
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
apiKey := uuid.New().String()
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
return
}
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
}
// GetProfile returns the current user's profile including API key.
func (h *UserHandler) GetProfile(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var user models.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"api_key": user.APIKey,
})
}
type UpdateProfileRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
CurrentPassword string `json:"current_password"`
}
// UpdateProfile updates the authenticated user's profile.
func (h *UserHandler) UpdateProfile(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get current user
var user models.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Check if email is already taken by another user
req.Email = strings.ToLower(req.Email)
var count int64
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
return
}
// If email is changing, verify password
if req.Email != user.Email {
if req.CurrentPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
return
}
if !user.CheckPassword(req.CurrentPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
}
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"name": req.Name,
"email": req.Email,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
}

View File

@@ -1,388 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
// Use unique DB for each test to avoid pollution
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{}, &models.Setting{})
return NewUserHandler(db), db
}
func TestUserHandler_GetSetupStatus(t *testing.T) {
handler, db := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/setup", handler.GetSetupStatus)
// No users -> setup required
req, _ := http.NewRequest("GET", "/setup", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "\"setupRequired\":true")
// Create user -> setup not required
db.Create(&models.User{Email: "test@example.com"})
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "\"setupRequired\":false")
}
func TestUserHandler_Setup(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/setup", handler.Setup)
// 1. Invalid JSON (Before setup is done)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/setup", bytes.NewBuffer([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 2. Valid Setup
body := map[string]string{
"name": "Admin",
"email": "admin@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), "Setup completed successfully")
// 3. Try again -> should fail (already setup)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestUserHandler_Setup_DBError(t *testing.T) {
// Can't easily mock DB error with sqlite memory unless we close it or something.
// But we can try to insert duplicate email if we had a unique constraint and pre-seeded data,
// but Setup checks if ANY user exists first.
// So if we have a user, it returns Forbidden.
// If we don't, it tries to create.
// If we want Create to fail, maybe invalid data that passes binding but fails DB constraint?
// User model has validation?
// Let's try empty password if allowed by binding but rejected by DB?
// Or very long string?
}
func TestUserHandler_RegenerateAPIKey(t *testing.T) {
handler, db := setupUserHandler(t)
user := &models.User{Email: "api@example.com"}
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.POST("/api-key", handler.RegenerateAPIKey)
req, _ := http.NewRequest("POST", "/api-key", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
assert.NotEmpty(t, resp["api_key"])
// Verify DB
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.Equal(t, resp["api_key"], updatedUser.APIKey)
}
func TestUserHandler_GetProfile(t *testing.T) {
handler, db := setupUserHandler(t)
user := &models.User{
Email: "profile@example.com",
Name: "Profile User",
APIKey: "existing-key",
}
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.GET("/profile", handler.GetProfile)
req, _ := http.NewRequest("GET", "/profile", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp models.User
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, user.Email, resp.Email)
assert.Equal(t, user.APIKey, resp.APIKey)
}
func TestUserHandler_RegisterRoutes(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
api := r.Group("/api")
handler.RegisterRoutes(api)
routes := r.Routes()
expectedRoutes := map[string]string{
"/api/setup": "GET,POST",
"/api/profile": "GET",
"/api/regenerate-api-key": "POST",
}
for path := range expectedRoutes {
found := false
for _, route := range routes {
if route.Path == path {
found = true
break
}
}
assert.True(t, found, "Route %s not found", path)
}
}
func TestUserHandler_Errors(t *testing.T) {
handler, db := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
// Middleware to simulate missing userID
r.GET("/profile-no-auth", func(c *gin.Context) {
// No userID set
handler.GetProfile(c)
})
r.POST("/api-key-no-auth", func(c *gin.Context) {
// No userID set
handler.RegenerateAPIKey(c)
})
// Middleware to simulate non-existent user
r.GET("/profile-not-found", func(c *gin.Context) {
c.Set("userID", uint(99999))
handler.GetProfile(c)
})
r.POST("/api-key-not-found", func(c *gin.Context) {
c.Set("userID", uint(99999))
handler.RegenerateAPIKey(c)
})
// Test Unauthorized
req, _ := http.NewRequest("GET", "/profile-no-auth", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
req, _ = http.NewRequest("POST", "/api-key-no-auth", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Test Not Found (GetProfile)
req, _ = http.NewRequest("GET", "/profile-not-found", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Test DB Error (RegenerateAPIKey) - Hard to mock DB error on update with sqlite memory,
// but we can try to update a non-existent user which GORM Update might not treat as error unless we check RowsAffected.
// The handler code: if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil
// Update on non-existent record usually returns nil error in GORM unless configured otherwise.
// However, let's see if we can force an error by closing DB? No, shared DB.
// We can drop the table?
db.Migrator().DropTable(&models.User{})
req, _ = http.NewRequest("POST", "/api-key-not-found", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
// If table missing, Update should fail
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestUserHandler_UpdateProfile(t *testing.T) {
handler, db := setupUserHandler(t)
// Create user
user := &models.User{
UUID: uuid.NewString(),
Email: "test@example.com",
Name: "Test User",
APIKey: uuid.NewString(),
}
user.SetPassword("password123")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.PUT("/profile", handler.UpdateProfile)
// 1. Success - Name only
t.Run("Success Name Only", func(t *testing.T) {
body := map[string]string{
"name": "Updated Name",
"email": "test@example.com",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.Equal(t, "Updated Name", updatedUser.Name)
})
// 2. Success - Email change with password
t.Run("Success Email Change", func(t *testing.T) {
body := map[string]string{
"name": "Updated Name",
"email": "newemail@example.com",
"current_password": "password123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.Equal(t, "newemail@example.com", updatedUser.Email)
})
// 3. Fail - Email change without password
t.Run("Fail Email Change No Password", func(t *testing.T) {
// Reset email
db.Model(user).Update("email", "test@example.com")
body := map[string]string{
"name": "Updated Name",
"email": "another@example.com",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
// 4. Fail - Email change wrong password
t.Run("Fail Email Change Wrong Password", func(t *testing.T) {
body := map[string]string{
"name": "Updated Name",
"email": "another@example.com",
"current_password": "wrongpassword",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
// 5. Fail - Email already in use
t.Run("Fail Email In Use", func(t *testing.T) {
// Create another user
otherUser := &models.User{
UUID: uuid.NewString(),
Email: "other@example.com",
Name: "Other User",
APIKey: uuid.NewString(),
}
db.Create(otherUser)
body := map[string]string{
"name": "Updated Name",
"email": "other@example.com",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusConflict, w.Code)
})
}
func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
// 1. Unauthorized (no userID)
r.PUT("/profile-no-auth", handler.UpdateProfile)
req, _ := http.NewRequest("PUT", "/profile-no-auth", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Middleware for subsequent tests
r.Use(func(c *gin.Context) {
c.Set("userID", uint(999)) // Non-existent ID
c.Next()
})
r.PUT("/profile", handler.UpdateProfile)
// 2. BindJSON error
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 3. User not found
body := map[string]string{"name": "New Name", "email": "new@example.com"}
jsonBody, _ := json.Marshal(body)
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}

View File

@@ -1,118 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestUserLoginAfterEmailChange(t *testing.T) {
// Setup DB
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{}, &models.Setting{})
// Setup Services and Handlers
cfg := config.Config{}
authService := services.NewAuthService(db, cfg)
authHandler := NewAuthHandler(authService)
userHandler := NewUserHandler(db)
// Setup Router
gin.SetMode(gin.TestMode)
r := gin.New()
// Register Routes
r.POST("/auth/login", authHandler.Login)
// Mock Auth Middleware for UpdateProfile
r.POST("/user/profile", func(c *gin.Context) {
// Simulate authenticated user
var user models.User
db.First(&user)
c.Set("userID", user.ID)
c.Set("role", user.Role)
c.Next()
}, userHandler.UpdateProfile)
// 1. Create Initial User
initialEmail := "initial@example.com"
password := "password123"
user, err := authService.Register(initialEmail, password, "Test User")
require.NoError(t, err)
require.NotNil(t, user)
// 2. Login with Initial Credentials (Verify it works)
loginBody := map[string]string{
"email": initialEmail,
"password": password,
}
jsonBody, _ := json.Marshal(loginBody)
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Initial login should succeed")
// 3. Update Profile (Change Email)
newEmail := "updated@example.com"
updateBody := map[string]string{
"name": "Test User Updated",
"email": newEmail,
"current_password": password,
}
jsonUpdate, _ := json.Marshal(updateBody)
req, _ = http.NewRequest("POST", "/user/profile", bytes.NewBuffer(jsonUpdate))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Update profile should succeed")
// Verify DB update
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.Equal(t, newEmail, updatedUser.Email, "Email should be updated in DB")
// 4. Login with New Email
loginBodyNew := map[string]string{
"email": newEmail,
"password": password,
}
jsonBodyNew, _ := json.Marshal(loginBodyNew)
req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyNew))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
// This is where the user says it fails
assert.Equal(t, http.StatusOK, w.Code, "Login with new email should succeed")
if w.Code != http.StatusOK {
t.Logf("Response Body: %s", w.Body.String())
}
// 5. Login with New Email (Different Case)
loginBodyCase := map[string]string{
"email": "Updated@Example.com", // Different case
"password": password,
}
jsonBodyCase, _ := json.Marshal(loginBodyCase)
req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyCase))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
// If this fails, it confirms case sensitivity issue
assert.Equal(t, http.StatusOK, w.Code, "Login with mixed case email should succeed")
}

View File

@@ -1,63 +0,0 @@
package middleware
import (
"net/http"
"strings"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
)
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// Try cookie
cookie, err := c.Cookie("auth_token")
if err == nil {
authHeader = "Bearer " + cookie
}
}
if authHeader == "" {
// Try query param
token := c.Query("token")
if token != "" {
authHeader = "Bearer " + token
}
}
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := authService.ValidateToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("role")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if userRole.(string) != role && userRole.(string) != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
c.Next()
}
}

View File

@@ -1,163 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAuthService(t *testing.T) *services.AuthService {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{})
cfg := config.Config{JWTSecret: "test-secret"}
return services.NewAuthService(db, cfg)
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// We pass nil for authService because we expect it to fail before using it
r.Use(AuthMiddleware(nil))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Authorization header required")
}
func TestRequireRole_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireRole_Forbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestAuthMiddleware_Cookie(t *testing.T) {
authService := setupAuthService(t)
user, err := authService.Register("test@example.com", "password", "Test User")
require.NoError(t, err)
token, err := authService.GenerateToken(user)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
userID, _ := c.Get("userID")
assert.Equal(t, user.ID, userID)
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_ValidToken(t *testing.T) {
authService := setupAuthService(t)
user, err := authService.Register("test@example.com", "password", "Test User")
require.NoError(t, err)
token, err := authService.GenerateToken(user)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
userID, _ := c.Get("userID")
assert.Equal(t, user.ID, userID)
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
authService := setupAuthService(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Invalid token")
}
func TestRequireRole_MissingRoleInContext(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// No role set in context
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

View File

@@ -1,221 +0,0 @@
package routes
import (
"context"
"fmt"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/middleware"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
// Register wires up API routes and performs automatic migrations.
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
// AutoMigrate all models for Issue #5 persistence layer
if err := db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.CaddyConfig{},
&models.RemoteServer{},
&models.SSLCertificate{},
&models.AccessList{},
&models.User{},
&models.Setting{},
&models.ImportSession{},
&models.Notification{},
&models.NotificationProvider{},
&models.UptimeMonitor{},
&models.UptimeHeartbeat{},
&models.Domain{},
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
router.GET("/api/v1/health", handlers.HealthHandler)
api := router.Group("/api/v1")
// Auth routes
authService := services.NewAuthService(db, cfg)
authHandler := handlers.NewAuthHandler(authService)
authMiddleware := middleware.AuthMiddleware(authService)
// Backup routes
backupService := services.NewBackupService(&cfg)
backupHandler := handlers.NewBackupHandler(backupService)
// Log routes
logService := services.NewLogService(&cfg)
logsHandler := handlers.NewLogsHandler(logService)
// Notification Service (needed for multiple handlers)
notificationService := services.NewNotificationService(db)
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/register", authHandler.Register)
protected := api.Group("/")
protected.Use(authMiddleware)
{
protected.POST("/auth/logout", authHandler.Logout)
protected.GET("/auth/me", authHandler.Me)
protected.POST("/auth/change-password", authHandler.ChangePassword)
// Backups
protected.GET("/backups", backupHandler.List)
protected.POST("/backups", backupHandler.Create)
protected.DELETE("/backups/:filename", backupHandler.Delete)
protected.GET("/backups/:filename/download", backupHandler.Download)
protected.POST("/backups/:filename/restore", backupHandler.Restore)
// Logs
protected.GET("/logs", logsHandler.List)
protected.GET("/logs/:filename", logsHandler.Read)
protected.GET("/logs/:filename/download", logsHandler.Download)
// Settings
settingsHandler := handlers.NewSettingsHandler(db)
protected.GET("/settings", settingsHandler.GetSettings)
protected.POST("/settings", settingsHandler.UpdateSetting)
// User Profile & API Key
userHandler := handlers.NewUserHandler(db)
protected.GET("/user/profile", userHandler.GetProfile)
protected.POST("/user/profile", userHandler.UpdateProfile)
protected.POST("/user/api-key", userHandler.RegenerateAPIKey)
// Updates
updateService := services.NewUpdateService()
updateHandler := handlers.NewUpdateHandler(updateService)
protected.GET("/system/updates", updateHandler.Check)
// Notifications
notificationHandler := handlers.NewNotificationHandler(notificationService)
protected.GET("/notifications", notificationHandler.List)
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
// Domains
domainHandler := handlers.NewDomainHandler(db, notificationService)
protected.GET("/domains", domainHandler.List)
protected.POST("/domains", domainHandler.Create)
protected.DELETE("/domains/:id", domainHandler.Delete)
// Docker
dockerService, err := services.NewDockerService()
if err == nil { // Only register if Docker is available
dockerHandler := handlers.NewDockerHandler(dockerService)
dockerHandler.RegisterRoutes(protected)
} else {
fmt.Printf("Warning: Docker service unavailable: %v\n", err)
}
// Uptime Service
uptimeService := services.NewUptimeService(db, notificationService)
uptimeHandler := handlers.NewUptimeHandler(uptimeService)
protected.GET("/uptime/monitors", uptimeHandler.List)
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
// Notification Providers
notificationProviderHandler := handlers.NewNotificationProviderHandler(notificationService)
protected.GET("/notifications/providers", notificationProviderHandler.List)
protected.POST("/notifications/providers", notificationProviderHandler.Create)
protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
protected.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
protected.POST("/notifications/providers/test", notificationProviderHandler.Test)
// Start background checker (every 1 minute)
go func() {
// Wait a bit for server to start
time.Sleep(30 * time.Second)
// Initial sync
if err := uptimeService.SyncMonitors(); err != nil {
fmt.Printf("Failed to sync monitors: %v\n", err)
}
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
uptimeService.SyncMonitors()
uptimeService.CheckAll()
}
}()
protected.POST("/system/uptime/check", func(c *gin.Context) {
go uptimeService.CheckAll()
c.JSON(200, gin.H{"message": "Uptime check started"})
})
}
// Caddy Manager
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir)
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService)
proxyHostHandler.RegisterRoutes(api)
remoteServerHandler := handlers.NewRemoteServerHandler(db, notificationService)
remoteServerHandler.RegisterRoutes(api)
userHandler := handlers.NewUserHandler(db)
userHandler.RegisterRoutes(api)
// Certificate routes
// Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage
// where ACME and certificates are stored (e.g. <CaddyConfigDir>/data).
caddyDataDir := cfg.CaddyConfigDir + "/data"
fmt.Printf("Using Caddy data directory for certificates scan: %s\n", caddyDataDir)
certService := services.NewCertificateService(caddyDataDir, db)
certHandler := handlers.NewCertificateHandler(certService, notificationService)
api.GET("/certificates", certHandler.List)
api.POST("/certificates", certHandler.Upload)
api.DELETE("/certificates/:id", certHandler.Delete)
// Initial Caddy Config Sync
go func() {
// Wait for Caddy to be ready (max 30 seconds)
ctx := context.Background()
timeout := time.After(30 * time.Second)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
ready := false
for {
select {
case <-timeout:
fmt.Println("Timeout waiting for Caddy to be ready")
return
case <-ticker.C:
if err := caddyManager.Ping(ctx); err == nil {
ready = true
goto Apply
}
}
}
Apply:
if ready {
// Apply config
if err := caddyManager.ApplyConfig(ctx); err != nil {
fmt.Printf("Failed to apply initial Caddy config: %v\n", err)
} else {
fmt.Printf("Successfully applied initial Caddy config\n")
}
}
}()
return nil
}
// RegisterImportHandler wires up import routes with config dependencies.
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir, mountPath)
api := router.Group("/api/v1")
importHandler.RegisterRoutes(api)
}

View File

@@ -1,53 +0,0 @@
package routes_test
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
func setupTestImportDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{})
return db
}
func TestRegisterImportHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestImportDB(t)
router := gin.New()
routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile")
// Verify routes are registered by checking the routes list
routeInfo := router.Routes()
expectedRoutes := map[string]bool{
"GET /api/v1/import/status": false,
"GET /api/v1/import/preview": false,
"POST /api/v1/import/upload": false,
"POST /api/v1/import/commit": false,
"DELETE /api/v1/import/cancel": false,
}
for _, route := range routeInfo {
key := route.Method + " " + route.Path
if _, exists := expectedRoutes[key]; exists {
expectedRoutes[key] = true
}
}
for route, found := range expectedRoutes {
assert.True(t, found, "route %s should be registered", route)
}
}

View File

@@ -1,41 +0,0 @@
package routes
import (
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestRegister(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
// Use in-memory DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{
JWTSecret: "test-secret",
}
err = Register(router, db, cfg)
assert.NoError(t, err)
// Verify some routes are registered
routes := router.Routes()
assert.NotEmpty(t, routes)
foundHealth := false
for _, r := range routes {
if r.Path == "/api/v1/health" {
foundHealth = true
break
}
}
assert.True(t, foundHealth, "Health route should be registered")
}