Add comprehensive tests for services and middleware

- Implement tests for AuthMiddleware to handle cookie and token authentication.
- Create tests for the Importer and Manager in the Caddy package.
- Enhance AuthService tests with password change and token validation scenarios.
- Introduce tests for CertificateService to validate certificate listing and expiry.
- Expand LogService tests to cover log querying and pagination.
- Add NotificationService tests for creating, listing, and marking notifications as read.
- Implement ProxyHostService tests for CRUD operations and unique domain validation.
- Create RemoteServerService tests for CRUD operations.
- Add UpdateService tests to mock GitHub API responses for version checking.
- Introduce UptimeService tests to check host availability and notifications for down hosts.
This commit is contained in:
Wikid82
2025-11-20 20:14:35 -05:00
parent 3b18ae80f2
commit 2eab570d54
26 changed files with 2187 additions and 113 deletions
@@ -1,68 +1,215 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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"
"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) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{}, &models.Setting{})
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
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)
handler, db := setupAuthHandler(t)
// Create user
user := &models.User{
Email: "test@example.com",
Name: "Test User",
// 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")
}
user.SetPassword("password123")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/login", handler.Login)
func TestAuthHandler_Register(t *testing.T) {
handler, _ := setupAuthHandler(t)
// Success
body := map[string]string{
"email": "test@example.com",
"password": "password123",
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")
}
jsonBody, _ := json.Marshal(body)
req, _ := http.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_Register_Duplicate(t *testing.T) {
handler, db := setupAuthHandler(t)
db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"})
// Failure
body["password"] = "wrong"
jsonBody, _ = json.Marshal(body)
req, _ = http.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/register", handler.Register)
assert.Equal(t, http.StatusUnauthorized, w.Code)
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, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
// Simulate middleware
r.Use(func(c *gin.Context) {
c.Set("userID", uint(1))
c.Set("role", "admin")
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(1), resp["user_id"])
assert.Equal(t, "admin", resp["role"])
}
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)
}
@@ -37,6 +37,10 @@ func (h *BackupHandler) Create(c *gin.Context) {
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
}
@@ -58,6 +62,10 @@ func (h *BackupHandler) Download(c *gin.Context) {
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
}
@@ -61,6 +61,8 @@ func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string
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
}
@@ -101,4 +103,45 @@ func TestBackupLifecycle(t *testing.T) {
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)
}
@@ -0,0 +1,40 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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)
service := services.NewCertificateService(tmpDir)
handler := NewCertificateHandler(service)
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)
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -327,3 +328,31 @@ func TestHealthHandler(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "ok", result["status"])
}
func TestRemoteServerHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
handler := handlers.NewRemoteServerHandler(db)
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)
}
@@ -0,0 +1,279 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"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() *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &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()
// 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 exists
sessionUUID := uuid.NewString()
session := &models.ImportSession{
UUID: sessionUUID,
Status: "pending",
CreatedAt: time.Now(),
}
db.Create(session)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/import/status", nil)
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"])
sessionMap, ok := resp["session"].(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, sessionUUID, sessionMap["uuid"])
}
func TestImportHandler_Cancel(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB()
// Seed active session
sessionUUID := uuid.NewString()
session := &models.ImportSession{
UUID: sessionUUID,
Status: "reviewing",
CreatedAt: time.Now(),
}
db.Create(session)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionUUID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.ImportSession
db.First(&updated, "uuid = ?", sessionUUID)
assert.Equal(t, "rejected", updated.Status)
}
func TestImportHandler_Commit(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB()
// Prepare parsed data
parsedData := `{"hosts":[{"domain_names":"example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"ssl_forced":true}],"conflicts":[],"errors":[]}`
// Seed active session
sessionUUID := uuid.NewString()
session := &models.ImportSession{
UUID: sessionUUID,
Status: "reviewing",
CreatedAt: time.Now(),
ParsedData: parsedData,
}
db.Create(session)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
router := gin.New()
router.POST("/import/commit", handler.Commit)
// Commit request
body := map[string]interface{}{
"session_uuid": sessionUUID,
"resolutions": map[string]string{},
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify session status
var updatedSession models.ImportSession
db.First(&updatedSession, "uuid = ?", sessionUUID)
assert.Equal(t, "committed", updatedSession.Status)
// Verify proxy host created
var host models.ProxyHost
db.First(&host, "domain_names = ?", "example.com")
assert.Equal(t, "example.com", host.DomainNames)
assert.Equal(t, "localhost", host.ForwardHost)
}
func TestImportHandler_Upload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB()
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
handler := handlers.NewImportHandler(db, fakeCaddy, "/tmp")
router := gin.New()
router.POST("/import/upload", handler.Upload)
// Create JSON body
body := map[string]string{
"content": "example.com {\n reverse_proxy localhost:8080\n}",
"filename": "Caddyfile",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify session created in DB
var session models.ImportSession
db.First(&session)
assert.NotEmpty(t, session.UUID)
assert.Equal(t, "pending", session.Status)
}
func TestImportHandler_GetPreview(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB()
// Seed active session
sessionUUID := uuid.NewString()
session := &models.ImportSession{
UUID: sessionUUID,
Status: "pending",
CreatedAt: time.Now(),
ParsedData: `{"hosts":[]}`,
}
db.Create(session)
handler := handlers.NewImportHandler(db, "echo", "/tmp")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
req, _ := http.NewRequest("GET", "/import/preview", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.NotNil(t, resp["hosts"])
}
func TestCheckMountedImport(t *testing.T) {
db := setupImportTestDB()
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "Caddyfile")
os.WriteFile(mountPath, []byte("example.com"), 0644)
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Verify session created
var session models.ImportSession
db.First(&session)
assert.NotEmpty(t, session.UUID)
}
func TestImportHandler_RegisterRoutes(t *testing.T) {
db := setupImportTestDB()
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_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB()
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)
}
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"os"
"strconv"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
@@ -43,6 +44,10 @@ func (h *LogsHandler) Read(c *gin.Context) {
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
}
@@ -109,4 +109,28 @@ func TestLogsLifecycle(t *testing.T) {
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)
}
@@ -0,0 +1,129 @@
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_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)
}
@@ -18,7 +18,8 @@ import (
func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
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{}))
@@ -113,3 +114,28 @@ func TestProxyHostErrors(t *testing.T) {
router.ServeHTTP(delResp, delReq)
require.Equal(t, http.StatusNotFound, delResp.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)
}
@@ -0,0 +1,103 @@
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"
)
func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServerHandler) {
db := setupTestDB()
// Ensure RemoteServer table exists
db.AutoMigrate(&models.RemoteServer{})
handler := handlers.NewRemoteServerHandler(db)
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-connection", handler.TestConnection)
return r, handler
}
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)
}
@@ -0,0 +1,93 @@
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)
}
@@ -0,0 +1,90 @@
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)
}
@@ -51,24 +51,184 @@ func TestUserHandler_Setup(t *testing.T) {
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, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), "Setup completed successfully")
// Try again -> should fail (already setup)
// 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)
}
@@ -5,10 +5,25 @@ import (
"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()
@@ -63,3 +78,86 @@ func TestRequireRole_Forbidden(t *testing.T) {
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)
}
+24
View File
@@ -0,0 +1,24 @@
package caddy
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewImporter(t *testing.T) {
importer := NewImporter("/usr/bin/caddy")
assert.NotNil(t, importer)
assert.Equal(t, "/usr/bin/caddy", importer.caddyBinaryPath)
importerDefault := NewImporter("")
assert.NotNil(t, importerDefault)
assert.Equal(t, "caddy", importerDefault.caddyBinaryPath)
}
func TestImporter_ParseCaddyfile_NotFound(t *testing.T) {
importer := NewImporter("caddy")
_, err := importer.ParseCaddyfile("non-existent-file")
assert.Error(t, err)
assert.Contains(t, err.Error(), "caddyfile not found")
}
+133 -40
View File
@@ -1,54 +1,147 @@
package caddy
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestManager_ApplyConfig(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" {
// Verify payload
var config Config
err := json.NewDecoder(r.Body).Decode(&config)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
// Mock Caddy Admin API
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == "POST" {
// Verify payload
var config Config
err := json.NewDecoder(r.Body).Decode(&config)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir)
// Create a host
host := models.ProxyHost{
DomainNames: "example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}
db.Create(&host)
// Apply Config
err = manager.ApplyConfig(context.Background())
assert.NoError(t, err)
}
w.WriteHeader(http.StatusOK)
return
func TestManager_ApplyConfig_Failure(t *testing.T) {
// Mock Caddy Admin API to fail
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer caddyServer.Close()
// Setup DB
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Manager
tmpDir := t.TempDir()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir)
// Create a host
host := models.ProxyHost{
DomainNames: "example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}
db.Create(&host)
// Apply Config - Should fail and trigger rollback
// Since we mock failure, rollback (which tries to apply the same config) will also fail.
err = manager.ApplyConfig(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "apply failed")
assert.Contains(t, err.Error(), "rollback also failed")
// Check if failure was recorded in DB
// Since rollback failed, recordConfigChange is NOT called.
var configLog models.CaddyConfig
err = db.First(&configLog).Error
assert.Error(t, err) // Should be record not found
assert.Equal(t, gorm.ErrRecordNotFound, err)
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
// Setup DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.ProxyHost{}, &models.Setting{})
func TestManager_RotateSnapshots(t *testing.T) {
// Setup Manager
tmpDir := t.TempDir()
// Seed DB
db.Create(&models.ProxyHost{
UUID: "test-uuid",
DomainNames: "example.com",
ForwardHost: "localhost",
ForwardPort: 8080,
Enabled: true,
})
// Mock Caddy Admin API (Success)
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer caddyServer.Close()
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, t.TempDir())
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Setting{}, &models.CaddyConfig{}))
err = manager.ApplyConfig(context.Background())
assert.NoError(t, err)
client := NewClient(caddyServer.URL)
manager := NewManager(client, db, tmpDir)
// Create 15 dummy config files
for i := 0; i < 15; i++ {
// Use past timestamps
ts := time.Now().Add(-time.Duration(i+1) * time.Minute).Unix()
fname := fmt.Sprintf("config-%d.json", ts)
f, _ := os.Create(filepath.Join(tmpDir, fname))
f.Close()
}
// Call ApplyConfig once
err = manager.ApplyConfig(context.Background())
assert.NoError(t, err)
// Check number of files
files, _ := os.ReadDir(tmpDir)
// Count files matching config-*.json
count := 0
for _, f := range files {
if filepath.Ext(f.Name()) == ".json" {
count++
}
}
// Should be 10 (kept)
assert.Equal(t, 10, count)
}
+57 -4
View File
@@ -1,6 +1,7 @@
package services
import (
"fmt"
"testing"
"time"
@@ -12,15 +13,16 @@ import (
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
func setupAuthTestDB(t *testing.T) *gorm.DB {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
return db
}
func TestAuthService_Register(t *testing.T) {
db := setupTestDB(t)
db := setupAuthTestDB(t)
cfg := config.Config{JWTSecret: "test-secret"}
service := NewAuthService(db, cfg)
@@ -38,7 +40,7 @@ func TestAuthService_Register(t *testing.T) {
}
func TestAuthService_Login(t *testing.T) {
db := setupTestDB(t)
db := setupAuthTestDB(t)
cfg := config.Config{JWTSecret: "test-secret"}
service := NewAuthService(db, cfg)
@@ -76,3 +78,54 @@ func TestAuthService_Login(t *testing.T) {
assert.Error(t, err)
assert.Equal(t, "account locked", err.Error())
}
func TestAuthService_ChangePassword(t *testing.T) {
db := setupAuthTestDB(t)
cfg := config.Config{JWTSecret: "test-secret"}
service := NewAuthService(db, cfg)
user, err := service.Register("test@example.com", "password123", "Test User")
require.NoError(t, err)
// Success
err = service.ChangePassword(user.ID, "password123", "newpassword")
assert.NoError(t, err)
// Verify login with new password
_, err = service.Login("test@example.com", "newpassword")
assert.NoError(t, err)
// Fail with old password
_, err = service.Login("test@example.com", "password123")
assert.Error(t, err)
// Fail with wrong current password
err = service.ChangePassword(user.ID, "wrong", "another")
assert.Error(t, err)
assert.Equal(t, "invalid current password", err.Error())
// Fail with non-existent user
err = service.ChangePassword(999, "password", "new")
assert.Error(t, err)
}
func TestAuthService_ValidateToken(t *testing.T) {
db := setupAuthTestDB(t)
cfg := config.Config{JWTSecret: "test-secret"}
service := NewAuthService(db, cfg)
user, err := service.Register("test@example.com", "password123", "Test User")
require.NoError(t, err)
token, err := service.Login("test@example.com", "password123")
require.NoError(t, err)
// Valid token
claims, err := service.ValidateToken(token)
assert.NoError(t, err)
assert.Equal(t, user.ID, claims.UserID)
// Invalid token
_, err = service.ValidateToken("invalid.token.string")
assert.Error(t, err)
}
@@ -0,0 +1,110 @@
package services
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func generateTestCert(t *testing.T, domain string, expiry time.Time) []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: expiry,
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 TestCertificateService_GetCertificateInfo(t *testing.T) {
// Create temp dir
tmpDir, err := os.MkdirTemp("", "cert-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cs := NewCertificateService(tmpDir)
// Case 1: Valid Certificate
domain := "example.com"
expiry := time.Now().Add(24 * time.Hour * 60) // 60 days
certPEM := generateTestCert(t, domain, expiry)
// Create cert directory
certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain)
err = os.MkdirAll(certDir, 0755)
if err != nil {
t.Fatalf("Failed to create cert dir: %v", err)
}
certPath := filepath.Join(certDir, domain+".crt")
err = os.WriteFile(certPath, certPEM, 0644)
if err != nil {
t.Fatalf("Failed to write cert file: %v", err)
}
// List Certificates
certs, err := cs.ListCertificates()
assert.NoError(t, err)
assert.Len(t, certs, 1)
if len(certs) > 0 {
assert.Equal(t, domain, certs[0].Domain)
assert.Equal(t, "valid", certs[0].Status)
// Check expiry within a margin
assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second)
}
// Case 2: Expired Certificate
expiredDomain := "expired.com"
expiredExpiry := time.Now().Add(-24 * time.Hour) // Yesterday
expiredCertPEM := generateTestCert(t, expiredDomain, expiredExpiry)
expiredCertDir := filepath.Join(tmpDir, "certificates", "other", expiredDomain)
err = os.MkdirAll(expiredCertDir, 0755)
assert.NoError(t, err)
expiredCertPath := filepath.Join(expiredCertDir, expiredDomain+".crt")
err = os.WriteFile(expiredCertPath, expiredCertPEM, 0644)
assert.NoError(t, err)
certs, err = cs.ListCertificates()
assert.NoError(t, err)
assert.Len(t, certs, 2)
// Find the expired one
var foundExpired bool
for _, c := range certs {
if c.Domain == expiredDomain {
assert.Equal(t, "expired", c.Status)
foundExpired = true
}
}
assert.True(t, foundExpired, "Should find expired certificate")
}
@@ -102,4 +102,58 @@ func TestLogService(t *testing.T) {
// Test GetLogPath non-existent
_, err = service.GetLogPath("missing.log")
assert.Error(t, err)
// Test ListLogs - Directory Not Exist
nonExistService := NewLogService(&config.Config{DatabasePath: filepath.Join(t.TempDir(), "missing", "cpm.db")})
logs, err = nonExistService.ListLogs()
require.NoError(t, err)
assert.Empty(t, logs)
// Test QueryLogs - Non-JSON Logs
plainContent := "2023/10/27 10:00:00 Application started\nJust a plain line\n"
err = os.WriteFile(filepath.Join(logsDir, "app.log"), []byte(plainContent), 0644)
require.NoError(t, err)
results, total, err = service.QueryLogs("app.log", models.LogFilter{Limit: 10})
require.NoError(t, err)
assert.Equal(t, int64(2), total)
// Reverse order check
assert.Equal(t, "Just a plain line", results[0].Msg)
assert.Equal(t, "Application started", results[1].Msg)
assert.Equal(t, "INFO", results[1].Level)
// Test QueryLogs - Pagination
// We have 2 logs in access.log
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 1, Offset: 0})
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, 500, results[0].Status) // Newest first
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 1, Offset: 1})
require.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, 200, results[0].Status) // Second newest
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 10, Offset: 5})
require.NoError(t, err)
assert.Empty(t, results)
// Test QueryLogs - Exact Status Match
results, total, err = service.QueryLogs("access.log", models.LogFilter{Status: "200", Limit: 10})
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, 200, results[0].Status)
// Test QueryLogs - Search Fields
// Search Method
results, total, err = service.QueryLogs("access.log", models.LogFilter{Search: "POST", Limit: 10})
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "POST", results[0].Request.Method)
// Search RemoteIP
results, total, err = service.QueryLogs("access.log", models.LogFilter{Search: "5.6.7.8", Limit: 10})
require.NoError(t, err)
assert.Equal(t, int64(1), total)
assert.Equal(t, "5.6.7.8", results[0].Request.RemoteIP)
}
@@ -0,0 +1,79 @@
package services
import (
"fmt"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupNotificationTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.Notification{})
return db
}
func TestNotificationService_Create(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
notif, err := svc.Create(models.NotificationTypeInfo, "Test", "Message")
require.NoError(t, err)
assert.Equal(t, "Test", notif.Title)
assert.Equal(t, "Message", notif.Message)
assert.False(t, notif.Read)
}
func TestNotificationService_List(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc.Create(models.NotificationTypeInfo, "N1", "M1")
svc.Create(models.NotificationTypeInfo, "N2", "M2")
list, err := svc.List(false)
require.NoError(t, err)
assert.Len(t, list, 2)
// Mark one as read
db.Model(&models.Notification{}).Where("title = ?", "N1").Update("read", true)
listUnread, err := svc.List(true)
require.NoError(t, err)
assert.Len(t, listUnread, 1)
assert.Equal(t, "N2", listUnread[0].Title)
}
func TestNotificationService_MarkAsRead(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
notif, _ := svc.Create(models.NotificationTypeInfo, "N1", "M1")
err := svc.MarkAsRead(fmt.Sprintf("%s", notif.ID))
require.NoError(t, err)
var updated models.Notification
db.First(&updated, "id = ?", notif.ID)
assert.True(t, updated.Read)
}
func TestNotificationService_MarkAllAsRead(t *testing.T) {
db := setupNotificationTestDB(t)
svc := NewNotificationService(db)
svc.Create(models.NotificationTypeInfo, "N1", "M1")
svc.Create(models.NotificationTypeInfo, "N2", "M2")
err := svc.MarkAllAsRead()
require.NoError(t, err)
var count int64
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
assert.Equal(t, int64(0), count)
}
@@ -1,6 +1,7 @@
package services
import (
"fmt"
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
@@ -11,9 +12,10 @@ import (
)
func setupProxyHostTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}))
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
return db
}
@@ -29,16 +31,110 @@ func TestProxyHostService_ValidateUniqueDomain(t *testing.T) {
}
require.NoError(t, db.Create(existing).Error)
// Test 1: Duplicate domain
err := service.ValidateUniqueDomain("example.com", 0)
assert.Error(t, err)
assert.Equal(t, "domain already exists", err.Error())
tests := []struct {
name string
domainNames string
excludeID uint
wantErr bool
}{
{
name: "New unique domain",
domainNames: "new.example.com",
excludeID: 0,
wantErr: false,
},
{
name: "Duplicate domain",
domainNames: "example.com",
excludeID: 0,
wantErr: true,
},
{
name: "Same domain but excluded ID (update self)",
domainNames: "example.com",
excludeID: existing.ID,
wantErr: false,
},
}
// Test 2: New domain
err = service.ValidateUniqueDomain("new.com", 0)
assert.NoError(t, err)
// Test 3: Update existing (exclude self)
err = service.ValidateUniqueDomain("example.com", existing.ID)
assert.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := service.ValidateUniqueDomain(tt.domainNames, tt.excludeID)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestProxyHostService_CRUD(t *testing.T) {
db := setupProxyHostTestDB(t)
service := NewProxyHostService(db)
// Create
host := &models.ProxyHost{
UUID: "uuid-1",
DomainNames: "test.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}
err := service.Create(host)
assert.NoError(t, err)
assert.NotZero(t, host.ID)
// Create Duplicate
dup := &models.ProxyHost{
UUID: "uuid-2",
DomainNames: "test.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8081,
}
err = service.Create(dup)
assert.Error(t, err)
// GetByID
fetched, err := service.GetByID(host.ID)
assert.NoError(t, err)
assert.Equal(t, host.DomainNames, fetched.DomainNames)
// GetByUUID
fetchedUUID, err := service.GetByUUID(host.UUID)
assert.NoError(t, err)
assert.Equal(t, host.ID, fetchedUUID.ID)
// Update
host.ForwardPort = 9090
err = service.Update(host)
assert.NoError(t, err)
fetched, err = service.GetByID(host.ID)
assert.NoError(t, err)
assert.Equal(t, 9090, fetched.ForwardPort)
// Update Duplicate
host2 := &models.ProxyHost{
UUID: "uuid-3",
DomainNames: "other.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 8080,
}
service.Create(host2)
host.DomainNames = "other.example.com" // Conflict with host2
err = service.Update(host)
assert.Error(t, err)
// List
hosts, err := service.List()
assert.NoError(t, err)
assert.Len(t, hosts, 2)
// Delete
err = service.Delete(host.ID)
assert.NoError(t, err)
_, err = service.GetByID(host.ID)
assert.Error(t, err)
}
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
@@ -11,9 +12,11 @@ import (
)
func setupRemoteServerTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&mode=memory"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.RemoteServer{}))
// Clear table
db.Exec("DELETE FROM remote_servers")
return db
}
@@ -47,3 +50,53 @@ func TestRemoteServerService_ValidateUniqueServer(t *testing.T) {
err = service.ValidateUniqueServer("Existing Server", "192.168.1.100", 8080, existing.ID)
assert.NoError(t, err)
}
func TestRemoteServerService_CRUD(t *testing.T) {
db := setupRemoteServerTestDB(t)
service := NewRemoteServerService(db)
// Create
rs := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Host: "192.168.1.100",
Port: 22,
Provider: "manual",
}
err := service.Create(rs)
require.NoError(t, err)
assert.NotZero(t, rs.ID)
assert.NotEmpty(t, rs.UUID)
// GetByID
fetched, err := service.GetByID(rs.ID)
require.NoError(t, err)
assert.Equal(t, rs.Name, fetched.Name)
// GetByUUID
fetchedUUID, err := service.GetByUUID(rs.UUID)
require.NoError(t, err)
assert.Equal(t, rs.ID, fetchedUUID.ID)
// Update
rs.Name = "Updated Server"
err = service.Update(rs)
require.NoError(t, err)
fetchedUpdated, err := service.GetByID(rs.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Server", fetchedUpdated.Name)
// List
list, err := service.List(false)
require.NoError(t, err)
assert.Len(t, list, 1)
// Delete
err = service.Delete(rs.ID)
require.NoError(t, err)
// Verify Delete
_, err = service.GetByID(rs.ID)
assert.Error(t, err)
}
+19 -3
View File
@@ -2,7 +2,6 @@ package services
import (
"encoding/json"
"fmt"
"net/http"
"time"
@@ -15,6 +14,7 @@ type UpdateService struct {
repoName string
lastCheck time.Time
cachedResult *UpdateInfo
apiURL string // For testing
}
type UpdateInfo struct {
@@ -33,19 +33,35 @@ func NewUpdateService() *UpdateService {
currentVersion: version.Version,
repoOwner: "Wikid82",
repoName: "CaddyProxyManagerPlus",
apiURL: "https://api.github.com/repos/Wikid82/CaddyProxyManagerPlus/releases/latest",
}
}
// SetAPIURL sets the GitHub API URL for testing.
func (s *UpdateService) SetAPIURL(url string) {
s.apiURL = url
}
// SetCurrentVersion sets the current version for testing.
func (s *UpdateService) SetCurrentVersion(v string) {
s.currentVersion = v
}
// ClearCache clears the update cache for testing.
func (s *UpdateService) ClearCache() {
s.cachedResult = nil
s.lastCheck = time.Time{}
}
func (s *UpdateService) CheckForUpdates() (*UpdateInfo, error) {
// Cache for 1 hour
if s.cachedResult != nil && time.Since(s.lastCheck) < 1*time.Hour {
return s.cachedResult, nil
}
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", s.repoOwner, s.repoName)
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", s.apiURL, nil)
if err != nil {
return nil, err
}
@@ -0,0 +1,78 @@
package services
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestUpdateService_CheckForUpdates(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
}
release := githubRelease{
TagName: "v1.0.0",
HTMLURL: "https://github.com/Wikid82/CaddyProxyManagerPlus/releases/tag/v1.0.0",
}
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
us := NewUpdateService()
us.SetAPIURL(server.URL + "/releases/latest")
// us.currentVersion is private, so we can't set it directly in test unless we export it or add a setter.
// However, NewUpdateService sets it from version.Version.
// We can temporarily change version.Version if it's a var, but it's likely a const or var in another package.
// Let's check version package.
// Assuming version.Version is a var we can change, or we add a SetCurrentVersion method for testing.
// For now, let's assume we can't change it easily without a setter.
// Let's add SetCurrentVersion to UpdateService for testing purposes.
us.SetCurrentVersion("0.9.0")
// Test Update Available
info, err := us.CheckForUpdates()
assert.NoError(t, err)
assert.True(t, info.Available)
assert.Equal(t, "v1.0.0", info.LatestVersion)
assert.Equal(t, "https://github.com/Wikid82/CaddyProxyManagerPlus/releases/tag/v1.0.0", info.ChangelogURL)
// Test No Update Available
us.SetCurrentVersion("1.0.0")
// us.cachedResult = nil // cachedResult is private
// us.lastCheck = time.Time{} // lastCheck is private
us.ClearCache() // Add this method
info, err = us.CheckForUpdates()
assert.NoError(t, err)
assert.False(t, info.Available)
assert.Equal(t, "v1.0.0", info.LatestVersion)
// Test Cache
// If we call again immediately, it should use cache.
// We can verify this by closing the server or changing the response, but cache logic is simple.
// Let's change the server handler? No, httptest server handler is fixed.
// But we can check if it returns the same object.
info2, err := us.CheckForUpdates()
assert.NoError(t, err)
assert.Equal(t, info, info2)
// Test Error (Server Down)
server.Close()
us.cachedResult = nil
us.lastCheck = time.Time{}
// Depending on implementation, it might return error or just available=false
// Implementation:
// resp, err := client.Do(req) -> returns error if connection refused
// if err != nil { return nil, err }
_, err = us.CheckForUpdates()
assert.Error(t, err)
}
@@ -0,0 +1,144 @@
package services
import (
"net"
"testing"
"time"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupUptimeTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to connect to database: %v", err)
}
err = db.AutoMigrate(&models.Notification{}, &models.Setting{}, &models.ProxyHost{})
if err != nil {
t.Fatalf("Failed to migrate database: %v", err)
}
return db
}
func TestUptimeService_CheckHost(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
us := NewUptimeService(db, ns)
// Test Case 1: Host is UP
// Start a listener on a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to start listener: %v", err)
}
defer listener.Close()
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
// Run check in a goroutine to accept connection if needed, but DialTimeout just needs handshake
// Actually DialTimeout will succeed if listener is accepting.
// We need to accept in a loop or just let it hang?
// net.Dial will succeed as soon as handshake is done.
// But we should probably accept to be clean.
go func() {
conn, err := listener.Accept()
if err == nil {
conn.Close()
}
}()
up := us.CheckHost("127.0.0.1", port)
assert.True(t, up, "Host should be UP")
// Test Case 2: Host is DOWN
// Use a port that is unlikely to be in use.
// Or just close the listener and try again on same port (might be TIME_WAIT issues though)
// Better to pick a random high port that nothing is listening on.
// But finding a free port is tricky.
// Let's just use a port we know is closed.
// Or use the same port after closing listener.
listener.Close()
// Give it a moment
time.Sleep(10 * time.Millisecond)
down := us.CheckHost("127.0.0.1", port)
assert.False(t, down, "Host should be DOWN")
}
func TestUptimeService_CheckAllHosts(t *testing.T) {
db := setupUptimeTestDB(t)
ns := NewNotificationService(db)
us := NewUptimeService(db, ns)
// Create a dummy listener for a "UP" host
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to start listener: %v", err)
}
defer listener.Close()
addr := listener.Addr().(*net.TCPAddr)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
conn.Close()
}
}()
// Seed ProxyHosts
upHost := models.ProxyHost{
UUID: "uuid-1",
DomainNames: "up.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: addr.Port,
Enabled: true,
}
db.Create(&upHost)
downHost := models.ProxyHost{
UUID: "uuid-2",
DomainNames: "down.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 54321, // Assuming this is closed
Enabled: true,
}
db.Create(&downHost)
disabledHost := models.ProxyHost{
UUID: "uuid-3",
DomainNames: "disabled.example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 54322,
Enabled: false,
}
// Force Enabled=false by using map or Select
db.Create(&disabledHost)
db.Model(&disabledHost).Update("Enabled", false)
// Run CheckAllHosts
us.CheckAllHosts()
// Verify Notifications
var notifications []models.Notification
db.Find(&notifications)
for _, n := range notifications {
t.Logf("Notification: %s - %s", n.Title, n.Message)
}
// We expect 1 notification for the downHost.
// upHost is UP -> no notification
// disabledHost is DISABLED -> no check -> no notification
assert.Equal(t, 1, len(notifications), "Should have 1 notification")
if len(notifications) > 0 {
assert.Contains(t, notifications[0].Message, "down.example.com", "Notification should mention the down host")
assert.Equal(t, models.NotificationTypeError, notifications[0].Type)
}
}