Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ require (

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
11 changes: 10 additions & 1 deletion agent/go.sum
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
146 changes: 146 additions & 0 deletions backend/internal/api/handlers/proxy_group_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package handlers

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
"gorm.io/gorm"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)

// ProxyGroupHandler handles CRUD operations for proxy groups.
type ProxyGroupHandler struct {
service *services.ProxyGroupService
db *gorm.DB
}

// NewProxyGroupHandler creates a new ProxyGroupHandler.
func NewProxyGroupHandler(db *gorm.DB) *ProxyGroupHandler {
return &ProxyGroupHandler{
service: services.NewProxyGroupService(db),
db: db,
}
}

// RegisterRoutes registers proxy group routes on the given router group.
func (h *ProxyGroupHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/proxy-groups", h.List)
router.POST("/proxy-groups", h.Create)
router.GET("/proxy-groups/:uuid", h.Get)
router.PUT("/proxy-groups/:uuid", h.Update)
router.DELETE("/proxy-groups/:uuid", h.Delete)
}

// List returns all proxy groups ordered by name.
func (h *ProxyGroupHandler) List(c *gin.Context) {
groups, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}

// Create creates a new proxy group.
func (h *ProxyGroupHandler) Create(c *gin.Context) {
var payload struct {
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

group := models.ProxyGroup{
Name: strings.TrimSpace(payload.Name),
Description: payload.Description,
Color: payload.Color,
}
if group.Color == "" {
group.Color = "#6366f1"
}

if err := h.service.Create(&group); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, group)
}

// Get returns a single proxy group by UUID, including host count.
func (h *ProxyGroupHandler) Get(c *gin.Context) {
group, err := h.service.GetByUUID(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"})
return
}
count, _ := h.service.GetHostCount(group.ID)
c.JSON(http.StatusOK, gin.H{
"uuid": group.UUID,
"name": group.Name,
"description": group.Description,
"color": group.Color,
"host_count": count,
"created_at": group.CreatedAt,
"updated_at": group.UpdatedAt,
})
}

// Update updates an existing proxy group by UUID.
func (h *ProxyGroupHandler) Update(c *gin.Context) {
group, err := h.service.GetByUUID(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"})
return
}

var payload struct {
Name *string `json:"name"`
Description *string `json:"description"`
Color *string `json:"color"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if payload.Name != nil {
trimmed := strings.TrimSpace(*payload.Name)
if trimmed == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"})
return
}
group.Name = trimmed
}
if payload.Description != nil {
group.Description = *payload.Description
}
if payload.Color != nil {
group.Color = *payload.Color
}

if err := h.service.Update(group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, group)
}

// Delete removes a proxy group by UUID, clearing host assignments.
func (h *ProxyGroupHandler) Delete(c *gin.Context) {
group, err := h.service.GetByUUID(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"})
return
}
if err := h.service.Delete(group.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
155 changes: 155 additions & 0 deletions backend/internal/api/handlers/proxy_group_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package handlers

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

func setupProxyGroupHandlerRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyGroup{}, &models.ProxyHost{}))

gin.SetMode(gin.TestMode)
router := gin.New()
h := NewProxyGroupHandler(db)
grp := router.Group("/")
h.RegisterRoutes(grp)
return router, db
}

func doRequest(router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
var b []byte
if body != nil {
b, _ = json.Marshal(body)
}
req, _ := http.NewRequest(method, path, bytes.NewBuffer(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}

func TestProxyGroupHandler_List_Empty(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodGet, "/proxy-groups", nil)
assert.Equal(t, http.StatusOK, w.Code)
var result []any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Empty(t, result)
}

func TestProxyGroupHandler_List_WithGroups(t *testing.T) {
router, db := setupProxyGroupHandlerRouter(t)
require.NoError(t, db.Create(&models.ProxyGroup{Name: "Beta"}).Error)
require.NoError(t, db.Create(&models.ProxyGroup{Name: "Alpha"}).Error)

w := doRequest(router, http.MethodGet, "/proxy-groups", nil)
assert.Equal(t, http.StatusOK, w.Code)
var result []map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Len(t, result, 2)
assert.Equal(t, "Alpha", result[0]["name"])
}

func TestProxyGroupHandler_Create_Valid(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{
"name": "Production",
"description": "Prod services",
"color": "#ff0000",
})
assert.Equal(t, http.StatusCreated, w.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "Production", result["name"])
assert.NotEmpty(t, result["uuid"])
}

func TestProxyGroupHandler_Create_EmptyName_400(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{"name": ""})
assert.Equal(t, http.StatusBadRequest, w.Code)
}

func TestProxyGroupHandler_Create_DefaultColor(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{"name": "NoColor"})
assert.Equal(t, http.StatusCreated, w.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "#6366f1", result["color"])
}

func TestProxyGroupHandler_Get_Found(t *testing.T) {
router, db := setupProxyGroupHandlerRouter(t)
group := &models.ProxyGroup{Name: "Find Me", Color: "#abc"}
require.NoError(t, db.Create(group).Error)

w := doRequest(router, http.MethodGet, "/proxy-groups/"+group.UUID, nil)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "Find Me", result["name"])
assert.Equal(t, float64(0), result["host_count"])
}

func TestProxyGroupHandler_Get_NotFound_404(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodGet, "/proxy-groups/nonexistent-uuid", nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}

func TestProxyGroupHandler_Update_PartialFields(t *testing.T) {
router, db := setupProxyGroupHandlerRouter(t)
group := &models.ProxyGroup{Name: "Old Name", Color: "#111"}
require.NoError(t, db.Create(group).Error)

newName := "New Name"
w := doRequest(router, http.MethodPut, "/proxy-groups/"+group.UUID, map[string]any{
"name": newName,
})
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result))
assert.Equal(t, "New Name", result["name"])
}

func TestProxyGroupHandler_Update_EmptyName_400(t *testing.T) {
router, db := setupProxyGroupHandlerRouter(t)
group := &models.ProxyGroup{Name: "Valid"}
require.NoError(t, db.Create(group).Error)

w := doRequest(router, http.MethodPut, "/proxy-groups/"+group.UUID, map[string]any{"name": " "})
assert.Equal(t, http.StatusBadRequest, w.Code)
}

func TestProxyGroupHandler_Delete_204(t *testing.T) {
router, db := setupProxyGroupHandlerRouter(t)
group := &models.ProxyGroup{Name: "Delete Me"}
require.NoError(t, db.Create(group).Error)

w := doRequest(router, http.MethodDelete, "/proxy-groups/"+group.UUID, nil)
assert.Equal(t, http.StatusNoContent, w.Code)

var count int64
db.Model(&models.ProxyGroup{}).Count(&count)
assert.Equal(t, int64(0), count)
}

func TestProxyGroupHandler_Delete_NotFound_404(t *testing.T) {
router, _ := setupProxyGroupHandlerRouter(t)
w := doRequest(router, http.MethodDelete, "/proxy-groups/nonexistent-uuid", nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
Loading
Loading