diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go index ac23901b..d27a6229 100644 --- a/backend/internal/api/handlers/auth_handler_test.go +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -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) } diff --git a/backend/internal/api/handlers/backup_handler.go b/backend/internal/api/handlers/backup_handler.go index 9bba940e..b4c217f0 100644 --- a/backend/internal/api/handlers/backup_handler.go +++ b/backend/internal/api/handlers/backup_handler.go @@ -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 } diff --git a/backend/internal/api/handlers/backup_handler_test.go b/backend/internal/api/handlers/backup_handler_test.go index c78c1edf..e10f06a3 100644 --- a/backend/internal/api/handlers/backup_handler_test.go +++ b/backend/internal/api/handlers/backup_handler_test.go @@ -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) } diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go new file mode 100644 index 00000000..116e547e --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 5b7db0c5..34083730 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go new file mode 100644 index 00000000..4c14a944 --- /dev/null +++ b/backend/internal/api/handlers/import_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/logs_handler.go b/backend/internal/api/handlers/logs_handler.go index 4eba4336..f34d1a36 100644 --- a/backend/internal/api/handlers/logs_handler.go +++ b/backend/internal/api/handlers/logs_handler.go @@ -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 } diff --git a/backend/internal/api/handlers/logs_handler_test.go b/backend/internal/api/handlers/logs_handler_test.go index 23161bd4..7c5160ee 100644 --- a/backend/internal/api/handlers/logs_handler_test.go +++ b/backend/internal/api/handlers/logs_handler_test.go @@ -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) } diff --git a/backend/internal/api/handlers/notification_handler_test.go b/backend/internal/api/handlers/notification_handler_test.go new file mode 100644 index 00000000..ade0afbc --- /dev/null +++ b/backend/internal/api/handlers/notification_handler_test.go @@ -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(), ¬ifications) + 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(), ¬ifications) + 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) +} diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index e9ca44ba..76fe3f8d 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/remote_server_handler_test.go b/backend/internal/api/handlers/remote_server_handler_test.go new file mode 100644 index 00000000..9bf8bc52 --- /dev/null +++ b/backend/internal/api/handlers/remote_server_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go new file mode 100644 index 00000000..c4ef4278 --- /dev/null +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/update_handler_test.go b/backend/internal/api/handlers/update_handler_test.go new file mode 100644 index 00000000..42cb26f2 --- /dev/null +++ b/backend/internal/api/handlers/update_handler_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 3166c3ab..dc7589a6 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -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) +} diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go index f66e6a35..72e2a5b3 100644 --- a/backend/internal/api/middleware/auth_test.go +++ b/backend/internal/api/middleware/auth_test.go @@ -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) +} diff --git a/backend/internal/caddy/importer_test.go b/backend/internal/caddy/importer_test.go new file mode 100644 index 00000000..0af554d2 --- /dev/null +++ b/backend/internal/caddy/importer_test.go @@ -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") +} diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 45251f02..7bb55252 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -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) } diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index a1e65bc2..bcccd01a 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -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) +} diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go new file mode 100644 index 00000000..7d18bc11 --- /dev/null +++ b/backend/internal/services/certificate_service_test.go @@ -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") +} diff --git a/backend/internal/services/log_service_test.go b/backend/internal/services/log_service_test.go index 1f9e93f5..313a87cb 100644 --- a/backend/internal/services/log_service_test.go +++ b/backend/internal/services/log_service_test.go @@ -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) } diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go new file mode 100644 index 00000000..c5056bc8 --- /dev/null +++ b/backend/internal/services/notification_service_test.go @@ -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) +} diff --git a/backend/internal/services/proxyhost_service_test.go b/backend/internal/services/proxyhost_service_test.go index d63e096c..a9868b09 100644 --- a/backend/internal/services/proxyhost_service_test.go +++ b/backend/internal/services/proxyhost_service_test.go @@ -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) } diff --git a/backend/internal/services/remoteserver_service_test.go b/backend/internal/services/remoteserver_service_test.go index 8f284497..9b145c77 100644 --- a/backend/internal/services/remoteserver_service_test.go +++ b/backend/internal/services/remoteserver_service_test.go @@ -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) +} diff --git a/backend/internal/services/update_service.go b/backend/internal/services/update_service.go index 44865680..a0a213dd 100644 --- a/backend/internal/services/update_service.go +++ b/backend/internal/services/update_service.go @@ -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 } diff --git a/backend/internal/services/update_service_test.go b/backend/internal/services/update_service_test.go new file mode 100644 index 00000000..7dfda4c1 --- /dev/null +++ b/backend/internal/services/update_service_test.go @@ -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) +} diff --git a/backend/internal/services/uptime_service_test.go b/backend/internal/services/uptime_service_test.go new file mode 100644 index 00000000..2307e231 --- /dev/null +++ b/backend/internal/services/uptime_service_test.go @@ -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(¬ifications) + + 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) + } +}