diff --git a/backend/go.sum b/backend/go.sum index dc47f3b5..0e1158f1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,3 @@ -cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -12,17 +10,14 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -38,7 +33,6 @@ github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -80,8 +74,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -98,7 +90,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -106,7 +97,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -144,27 +134,17 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= -github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -206,15 +186,12 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= @@ -226,7 +203,6 @@ google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn 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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -238,4 +214,3 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go index 7281f36f..019b2e92 100644 --- a/backend/internal/api/handlers/handlers_test.go +++ b/backend/internal/api/handlers/handlers_test.go @@ -267,7 +267,7 @@ func TestProxyHostHandler_List(t *testing.T) { db.Create(host) ns := services.NewNotificationService(db) - handler := handlers.NewProxyHostHandler(db, nil, ns) + handler := handlers.NewProxyHostHandler(db, nil, ns, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) @@ -290,7 +290,7 @@ func TestProxyHostHandler_Create(t *testing.T) { db := setupTestDB() ns := services.NewNotificationService(db) - handler := handlers.NewProxyHostHandler(db, nil, ns) + handler := handlers.NewProxyHostHandler(db, nil, ns, nil) router := gin.New() handler.RegisterRoutes(router.Group("/api/v1")) diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 1469fabb..50551f18 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -22,14 +22,16 @@ type ProxyHostHandler struct { service *services.ProxyHostService caddyManager *caddy.Manager notificationService *services.NotificationService + uptimeService *services.UptimeService } // NewProxyHostHandler creates a new proxy host handler. -func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService) *ProxyHostHandler { +func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler { return &ProxyHostHandler{ service: services.NewProxyHostService(db), caddyManager: caddyManager, notificationService: ns, + uptimeService: uptimeService, } } @@ -215,6 +217,19 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) { return } + // check if we should also delete associated uptime monitors (query param: delete_uptime=true) + deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true" + + if deleteUptime && h.uptimeService != nil { + // Find all monitors referencing this proxy host and delete each + var monitors []models.UptimeMonitor + if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil { + for _, m := range monitors { + _ = h.uptimeService.DeleteMonitor(m.ID) + } + } + } + if err := h.service.Delete(host.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 7536c1c3..597fa5ce 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -29,7 +29,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{})) ns := services.NewNotificationService(db) - h := NewProxyHostHandler(db, nil, ns) + h := NewProxyHostHandler(db, nil, ns, nil) r := gin.New() api := r.Group("/api/v1") h.RegisterRoutes(api) @@ -97,6 +97,47 @@ func TestProxyHostLifecycle(t *testing.T) { require.Equal(t, http.StatusNotFound, getResp2.Code) } +func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) { + // Setup DB and router with uptime service + dsn := "file:test-delete-uptime?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{})) + + ns := services.NewNotificationService(db) + us := services.NewUptimeService(db, ns) + h := NewProxyHostHandler(db, nil, ns, us) + + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + // Create host and monitor + host := models.ProxyHost{UUID: "ph-delete-1", Name: "Del Host", DomainNames: "del.test", ForwardHost: "127.0.0.1", ForwardPort: 80} + db.Create(&host) + monitor := models.UptimeMonitor{ID: "ut-mon-1", ProxyHostID: &host.ID, Name: "linked", Type: "http", URL: "http://del.test"} + db.Create(&monitor) + + // Ensure monitor exists + var count int64 + db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", host.ID).Count(&count) + require.Equal(t, int64(1), count) + + // Delete host with delete_uptime=true + req := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID+"?delete_uptime=true", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + // Host should be deleted + var ph models.ProxyHost + require.Error(t, db.First(&ph, "uuid = ?", host.UUID).Error) + + // Monitor should also be deleted + db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", host.ID).Count(&count) + require.Equal(t, int64(0), count) +} + func TestProxyHostErrors(t *testing.T) { // Mock Caddy Admin API that fails caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -117,7 +158,7 @@ func TestProxyHostErrors(t *testing.T) { // Setup Handler ns := services.NewNotificationService(db) - h := NewProxyHostHandler(db, manager, ns) + h := NewProxyHostHandler(db, manager, ns, nil) r := gin.New() api := r.Group("/api/v1") h.RegisterRoutes(api) @@ -304,7 +345,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) { // Setup Handler ns := services.NewNotificationService(db) - h := NewProxyHostHandler(db, manager, ns) + h := NewProxyHostHandler(db, manager, ns, nil) r := gin.New() api := r.Group("/api/v1") h.RegisterRoutes(api) diff --git a/backend/internal/api/handlers/uptime_handler.go b/backend/internal/api/handlers/uptime_handler.go index 84331293..9c0a3198 100644 --- a/backend/internal/api/handlers/uptime_handler.go +++ b/backend/internal/api/handlers/uptime_handler.go @@ -53,3 +53,21 @@ func (h *UptimeHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, monitor) } + +func (h *UptimeHandler) Sync(c *gin.Context) { + if err := h.service.SyncMonitors(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Sync started"}) +} + +// Delete removes a monitor and its associated data +func (h *UptimeHandler) Delete(c *gin.Context) { + id := c.Param("id") + if err := h.service.DeleteMonitor(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"}) +} diff --git a/backend/internal/api/handlers/uptime_handler_test.go b/backend/internal/api/handlers/uptime_handler_test.go index 9fdbcfe0..d3915f4c 100644 --- a/backend/internal/api/handlers/uptime_handler_test.go +++ b/backend/internal/api/handlers/uptime_handler_test.go @@ -23,7 +23,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.NotificationProvider{}, &models.Notification{})) + require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{})) ns := services.NewNotificationService(db) service := services.NewUptimeService(db, ns) @@ -33,8 +33,10 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { api := r.Group("/api/v1") uptime := api.Group("/uptime") uptime.GET("", handler.List) - uptime.GET("/:id/history", handler.GetHistory) - uptime.PUT("/:id", handler.Update) + uptime.GET(":id/history", handler.GetHistory) + uptime.PUT(":id", handler.Update) + uptime.DELETE(":id", handler.Delete) + uptime.POST("/sync", handler.Sync) return r, db } @@ -160,3 +162,58 @@ func TestUptimeHandler_Update(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, w.Code) }) } + +func TestUptimeHandler_DeleteAndSync(t *testing.T) { + t.Run("delete monitor", func(t *testing.T) { + r, db := setupUptimeHandlerTest(t) + + monitor := models.UptimeMonitor{ID: "mon-delete", Name: "ToDelete", Type: "http", URL: "http://example.com"} + db.Create(&monitor) + + req, _ := http.NewRequest("DELETE", "/api/v1/uptime/mon-delete", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var m models.UptimeMonitor + require.Error(t, db.First(&m, "id = ?", "mon-delete").Error) + }) + + t.Run("sync creates monitor for proxy host", func(t *testing.T) { + r, db := setupUptimeHandlerTest(t) + + // Create a proxy host to be synced to an uptime monitor + host := models.ProxyHost{UUID: "ph-up-1", Name: "Test Host", DomainNames: "sync.example.com", ForwardHost: "127.0.0.1", ForwardPort: 80, Enabled: true} + db.Create(&host) + + req, _ := http.NewRequest("POST", "/api/v1/uptime/sync", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var monitors []models.UptimeMonitor + db.Where("proxy_host_id = ?", host.ID).Find(&monitors) + assert.Len(t, monitors, 1) + assert.Equal(t, "Test Host", monitors[0].Name) + }) + + t.Run("update enabled via PUT", func(t *testing.T) { + r, db := setupUptimeHandlerTest(t) + + monitor := models.UptimeMonitor{ID: "mon-enable", Name: "ToToggle", Type: "http", URL: "http://example.com", Enabled: true} + db.Create(&monitor) + + updates := map[string]interface{}{"enabled": false} + body, _ := json.Marshal(updates) + req, _ := http.NewRequest("PUT", "/api/v1/uptime/mon-enable", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var result models.UptimeMonitor + err := json.Unmarshal(w.Body.Bytes(), &result) + require.NoError(t, err) + assert.False(t, result.Enabled) + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index e94141fa..6b9efaef 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -87,6 +87,9 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { api.POST("/auth/login", authHandler.Login) api.POST("/auth/register", authHandler.Register) + // Uptime Service - define early so it can be used during route registration + uptimeService := services.NewUptimeService(db, notificationService) + protected := api.Group("/") protected.Use(authMiddleware) { @@ -158,6 +161,8 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/uptime/monitors", uptimeHandler.List) protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory) protected.PUT("/uptime/monitors/:id", uptimeHandler.Update) + protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete) + protected.POST("/uptime/sync", uptimeHandler.Sync) // Notification Providers notificationProviderHandler := handlers.NewNotificationProviderHandler(notificationService) @@ -214,7 +219,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { caddyClient := caddy.NewClient(cfg.CaddyAdminAPI) caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging) - proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService) + proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) proxyHostHandler.RegisterRoutes(api) remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go index fb578f60..b925e4b2 100644 --- a/backend/internal/services/uptime_service.go +++ b/backend/internal/services/uptime_service.go @@ -821,6 +821,9 @@ func (s *UptimeService) UpdateMonitor(id string, updates map[string]interface{}) if val, ok := updates["interval"]; ok { allowedUpdates["interval"] = val } + if val, ok := updates["enabled"]; ok { + allowedUpdates["enabled"] = val + } // Add other fields as needed, but be careful not to overwrite SyncMonitors logic if err := s.DB.Model(&monitor).Updates(allowedUpdates).Error; err != nil { @@ -829,3 +832,27 @@ func (s *UptimeService) UpdateMonitor(id string, updates map[string]interface{}) return &monitor, nil } + +// DeleteMonitor removes a monitor and its heartbeats, and optionally cleans up the parent UptimeHost. +func (s *UptimeService) DeleteMonitor(id string) error { + // Find monitor + var monitor models.UptimeMonitor + if err := s.DB.First(&monitor, "id = ?", id).Error; err != nil { + return err + } + + // Delete heartbeats + if err := s.DB.Where("monitor_id = ?", id).Delete(&models.UptimeHeartbeat{}).Error; err != nil { + return err + } + + // Delete the monitor + if err := s.DB.Delete(&monitor).Error; err != nil { + return err + } + + // If no other monitors reference the uptime host, we don't automatically delete the host. + // Leave host cleanup to a manual process or separate endpoint. + + return nil +} diff --git a/backend/internal/services/uptime_service_unit_test.go b/backend/internal/services/uptime_service_unit_test.go new file mode 100644 index 00000000..9975fa46 --- /dev/null +++ b/backend/internal/services/uptime_service_unit_test.go @@ -0,0 +1,57 @@ +package services + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +func setupUnitTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{})) + return db +} + +func TestUpdateMonitorEnabled_Unit(t *testing.T) { + db := setupUnitTestDB(t) + svc := NewUptimeService(db, nil) + + monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-test", URL: "http://example.com", Interval: 60, Enabled: true} + require.NoError(t, db.Create(&monitor).Error) + + r, err := svc.UpdateMonitor(monitor.ID, map[string]interface{}{"enabled": false}) + require.NoError(t, err) + require.False(t, r.Enabled) + + var m models.UptimeMonitor + require.NoError(t, db.First(&m, "id = ?", monitor.ID).Error) + require.False(t, m.Enabled) +} + +func TestDeleteMonitorDeletesHeartbeats_Unit(t *testing.T) { + db := setupUnitTestDB(t) + svc := NewUptimeService(db, nil) + + monitor := models.UptimeMonitor{ID: uuid.New().String(), Name: "unit-delete", URL: "http://example.com", Interval: 60, Enabled: true} + require.NoError(t, db.Create(&monitor).Error) + + hb := models.UptimeHeartbeat{MonitorID: monitor.ID, Status: "up", Latency: 10, CreatedAt: time.Now()} + require.NoError(t, db.Create(&hb).Error) + + require.NoError(t, svc.DeleteMonitor(monitor.ID)) + + var m models.UptimeMonitor + require.Error(t, db.First(&m, "id = ?", monitor.ID).Error) + + var count int64 + db.Model(&models.UptimeHeartbeat{}).Where("monitor_id = ?", monitor.ID).Count(&count) + require.Equal(t, int64(0), count) +} diff --git a/docs/issues/ACL-testing-tasks.md b/docs/issues/ACL-testing-tasks.md new file mode 100644 index 00000000..11041376 --- /dev/null +++ b/docs/issues/ACL-testing-tasks.md @@ -0,0 +1,53 @@ + Tasks + +Repository: Wikid82/Charon +Branch: feature/beta-release + +Purpose +------- +Create a tracked issue and sub-tasks to validate ACL-related changes introduced on the `feature/beta-release` branch. This file records the scope, test steps, and sub-issues so we can open a GitHub issue later or link this file in the issue body. + +Top-level checklist +- [ ] Open GitHub Issue "ACL: Test and validate ACL changes (feature/beta-release)" and link this file +- [ ] Assign owner and target date + +Sub-tasks (suggested GitHub issue checklist items) +1) Unit & Service Tests + - [ ] Add/verify unit tests for `internal/services/access_list_service.go` CRUD + validation + - [ ] Add tests for `internal/api/handlers/access_list_handler.go` endpoints (create/list/get/update/delete) + - Acceptance: all handler tests pass and coverage for `internal/api/handlers` rises by at least 3%. + +2) Integration Tests + - [ ] Test ACL interactions with proxy hosts: ensure blocked/allowed behavior when ACLs applied to hosts + - [ ] Test ACL import via Caddy import workflow (multi-site) — ensure imported ACLs attach correctly + - Acceptance: end-to-end requests are blocked/allowed per ACL rules in an integration harness. + +3) UI & API Validation + - [ ] Validate frontend UI toggles for ACL enable/disable reflect DB state + - [ ] Verify API endpoints that toggle ACL mode return correct status and persist in `settings` + - Acceptance: toggles update DB and the UI shows consistent state after refresh. + +4) Security & Edge Cases + - [ ] Test denied webhook payloads / WAF interactions when ACLs are present + - [ ] Confirm rate-limit and CrowdSec interactions do not conflict with ACL rules + - Acceptance: no regressions found; documented edge cases. + +5) Documentation & Release Notes + - [ ] Update `docs/features.md` with any behavior changes + - [ ] Add a short note in release notes describing ACL test coverage and migration steps + +Manual Test Steps (quick guide) +- Set up local environment: + 1. `cd backend && go run ./cmd/api` (or use docker compose) + 2. Run frontend dev server: `cd frontend && npm run dev` +- Create an ACL via API or UI; attach it to a Proxy Host; verify request behavior. +- Import Caddyfiles (single & multi-site) with ACL directives and validate mapping. + +Issue metadata (suggested) +- Title: ACL: Test and validate ACL changes (feature/beta-release) +- Labels: testing, needs-triage, acl, regression +- Assignees: @ +- Milestone: to be set + +Notes +- Keep this file as the canonical checklist and paste into the GitHub issue body when opening the issue. diff --git a/frontend/src/api/__tests__/backups.test.ts b/frontend/src/api/__tests__/backups.test.ts new file mode 100644 index 00000000..eb063070 --- /dev/null +++ b/frontend/src/api/__tests__/backups.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import client from '../../api/client' +import { getBackups, createBackup, restoreBackup, deleteBackup } from '../backups' + +describe('backups api', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('getBackups returns list', async () => { + const mockData = [{ filename: 'b1.zip', size: 123, time: '2025-01-01T00:00:00Z' }] + vi.spyOn(client, 'get').mockResolvedValueOnce({ data: mockData }) + const res = await getBackups() + expect(res).toEqual(mockData) + }) + + it('createBackup returns filename', async () => { + vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { filename: 'b2.zip' } }) + const res = await createBackup() + expect(res).toEqual({ filename: 'b2.zip' }) + }) + + it('restoreBackup posts to restore endpoint', async () => { + const spy = vi.spyOn(client, 'post').mockResolvedValueOnce({}) + await restoreBackup('b3.zip') + expect(spy).toHaveBeenCalledWith('/backups/b3.zip/restore') + }) + + it('deleteBackup deletes backup', async () => { + const spy = vi.spyOn(client, 'delete').mockResolvedValueOnce({}) + await deleteBackup('b3.zip') + expect(spy).toHaveBeenCalledWith('/backups/b3.zip') + }) +}) diff --git a/frontend/src/api/featureFlags.test.ts b/frontend/src/api/featureFlags.test.ts index 1898282f..747ed717 100644 --- a/frontend/src/api/featureFlags.test.ts +++ b/frontend/src/api/featureFlags.test.ts @@ -15,12 +15,12 @@ describe('featureFlags API', () => { it('fetches feature flags', async () => { const flags = await getFeatureFlags() expect(flags['feature.global.enabled']).toBe(true) - expect((client.get as any)).toHaveBeenCalled() + expect(vi.mocked(client.get)).toHaveBeenCalled() }) it('updates feature flags', async () => { const resp = await updateFeatureFlags({ 'feature.global.enabled': false }) expect(resp).toEqual({ status: 'ok' }) - expect((client.put as any)).toHaveBeenCalledWith('/feature-flags', { 'feature.global.enabled': false }) + expect(vi.mocked(client.put)).toHaveBeenCalledWith('/feature-flags', { 'feature.global.enabled': false }) }) }) diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 2d22f67c..b4b75ad8 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -40,12 +40,17 @@ export const testProvider = async (provider: Partial) => { }; export const getTemplates = async () => { - const response = await client.get('/notifications/templates'); + const response = await client.get('/notifications/templates'); return response.data; }; -export const previewProvider = async (provider: Partial, data?: Record) => { - const payload: any = { ...provider }; +export interface NotificationTemplate { + id: string; + name: string; +} + +export const previewProvider = async (provider: Partial, data?: Record) => { + const payload: Record = { ...provider } as Record; if (data) payload.data = data; const response = await client.post('/notifications/providers/preview', payload); return response.data; @@ -80,8 +85,8 @@ export const deleteExternalTemplate = async (id: string) => { await client.delete(`/notifications/external-templates/${id}`); }; -export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record) => { - const payload: any = {}; +export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record) => { + const payload: Record = {}; if (templateId) payload.template_id = templateId; if (template) payload.template = template; if (data) payload.data = data; diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index a0ffc630..6c1ae286 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -64,8 +64,9 @@ export const updateProxyHost = async (uuid: string, host: Partial): P return data; }; -export const deleteProxyHost = async (uuid: string): Promise => { - await client.delete(`/proxy-hosts/${uuid}`); +export const deleteProxyHost = async (uuid: string, deleteUptime?: boolean): Promise => { + const url = `/proxy-hosts/${uuid}${deleteUptime ? '?delete_uptime=true' : ''}` + await client.delete(url); }; export const testProxyHostConnection = async (host: string, port: number): Promise => { diff --git a/frontend/src/api/uptime.ts b/frontend/src/api/uptime.ts index bdee342d..ad182261 100644 --- a/frontend/src/api/uptime.ts +++ b/frontend/src/api/uptime.ts @@ -2,6 +2,7 @@ import client from './client'; export interface UptimeMonitor { id: string; + upstream_host?: string; proxy_host_id?: number; remote_server_id?: number; name: string; @@ -10,7 +11,7 @@ export interface UptimeMonitor { interval: number; enabled: boolean; status: string; - last_check: string; + last_check?: string | null; latency: number; max_retries: number; } @@ -38,3 +39,13 @@ export const updateMonitor = async (id: string, data: Partial) => const response = await client.put(`/uptime/monitors/${id}`, data); return response.data; }; + +export const deleteMonitor = async (id: string) => { + const response = await client.delete(`/uptime/monitors/${id}`); + return response.data; +}; + +export async function syncMonitors(body?: { interval?: number; max_retries?: number }) { + const res = await client.post('/uptime/sync', body || {}); + return res.data; +} diff --git a/frontend/src/components/ImportReviewTable.tsx b/frontend/src/components/ImportReviewTable.tsx index 82ead797..05c324ed 100644 --- a/frontend/src/components/ImportReviewTable.tsx +++ b/frontend/src/components/ImportReviewTable.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import React, { useState } from 'react' import { AlertTriangle, CheckCircle2 } from 'lucide-react' interface HostPreview { @@ -150,15 +150,15 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e - {hosts.map((h, idx) => { + {hosts.map((h) => { const domain = h.domain_names const hasConflict = conflicts.includes(domain) const isExpanded = expandedRows.has(domain) const details = conflictDetails?.[domain] return ( - <> - + + {hasConflict && isExpanded && details && ( - +
@@ -316,7 +316,7 @@ export default function ImportReviewTable({ hosts, conflicts, conflictDetails, e )} - + ) })} diff --git a/frontend/src/components/ImportSitesModal.tsx b/frontend/src/components/ImportSitesModal.tsx index f84c4eec..817865d6 100644 --- a/frontend/src/components/ImportSitesModal.tsx +++ b/frontend/src/components/ImportSitesModal.tsx @@ -32,8 +32,9 @@ export default function ImportSitesModal({ visible, onClose, onUploaded }: Props setLoading(false) if (onUploaded) onUploaded() onClose() - } catch (err: any) { - setError(err?.message || 'Upload failed') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg || 'Upload failed') setLoading(false) } } diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index c8715155..265610ef 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react' import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info } from 'lucide-react' +import { toast } from 'react-hot-toast' import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts' import { testProxyHostConnection } from '../api/proxyHosts' +import { syncMonitors } from '../api/uptime' import { useRemoteServers } from '../hooks/useRemoteServers' import { useDomains } from '../hooks/useDomains' import { useCertificates } from '../hooks/useCertificates' @@ -85,7 +87,8 @@ interface ProxyHostFormProps { } export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) { - const [formData, setFormData] = useState({ + type ProxyHostFormState = Partial & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number } + const [formData, setFormData] = useState({ name: host?.name || '', domain_names: host?.domain_names || '', forward_scheme: host?.forward_scheme || 'http', @@ -144,7 +147,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor // Get the external URL for this proxy host const getExternalUrl = () => { - const domain = formData.domain_names.split(',')[0]?.trim() + const domain = (formData.domain_names ?? '').split(',')[0]?.trim() if (!domain) return '' return `https://${domain}:443` } @@ -269,28 +272,38 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [nameError, setNameError] = useState(null) + const [addUptime, setAddUptime] = useState(false) + const [uptimeInterval, setUptimeInterval] = useState(60) + const [uptimeMaxRetries, setUptimeMaxRetries] = useState(3) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) - setError(null) - setNameError(null) - - // Validate name is required - if (!formData.name.trim()) { - setNameError('Name is required') - setLoading(false) - return - } - try { - await onSubmit(formData) - } catch (err: unknown) { - console.error("Submit error:", err) - // Extract error message from axios response if available - const errorObj = err as { response?: { data?: { error?: string } }; message?: string } - const message = errorObj.response?.data?.error || errorObj.message || 'Failed to save proxy host' + const payload = { ...formData } + // strip temporary uptime-only flags from payload by destructuring + const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState + void _addUptime; void _uptimeInterval; void _uptimeMaxRetries; + const res = await onSubmit(payloadWithoutUptime) + + // if user asked to add uptime, request server to sync monitors + if (addUptime) { + try { + await syncMonitors({ interval: uptimeInterval, max_retries: uptimeMaxRetries }) + toast.success('Requested uptime monitor creation') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + toast.error(msg || 'Failed to request uptime creation') + } + } + + onCancel() + return res + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save host' setError(message) + toast.error(message) + throw err } finally { setLoading(false) } @@ -555,7 +568,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor min="1" max="65535" value={formData.forward_port} - onChange={e => setFormData({ ...formData, forward_port: parseInt(e.target.value) })} + onChange={e => { + const v = parseInt(e.target.value) + setFormData({ ...formData, forward_port: Number.isNaN(v) ? 0 : v }) + }} className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
@@ -875,6 +891,44 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
+ {/* Uptime option */} +
+ + + {addUptime && ( +
+
+ + setUptimeInterval(parseInt(e.target.value || '60'))} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white" + /> +
+
+ + setUptimeMaxRetries(parseInt(e.target.value || '3'))} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white" + /> +
+
+ )} +
+ {/* Actions */}
diff --git a/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx new file mode 100644 index 00000000..41255289 --- /dev/null +++ b/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ProxyHostForm from '../ProxyHostForm' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('../../api/uptime', () => ({ + syncMonitors: vi.fn(() => Promise.resolve({})), +})) + +// Minimal hook mocks used by the component +vi.mock('../../hooks/useRemoteServers', () => ({ + useRemoteServers: vi.fn(() => ({ + servers: [], + isLoading: false, + error: null, + createRemoteServer: vi.fn(), + updateRemoteServer: vi.fn(), + deleteRemoteServer: vi.fn(), + })), +})) + +vi.mock('../../hooks/useDocker', () => ({ + useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })), +})) + +vi.mock('../../hooks/useDomains', () => ({ + useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })), +})) + +vi.mock('../../hooks/useCertificates', () => ({ + useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })), +})) + +// stub global fetch for health endpoint +vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) }))) + +describe('ProxyHostForm Add Uptime flow', () => { + it('submits host and requests uptime sync when Add Uptime is checked', async () => { + const onSubmit = vi.fn(() => Promise.resolve()) + const onCancel = vi.fn() + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + ) + + // Fill required fields + await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1') + await userEvent.clear(screen.getByLabelText(/^Port$/)) + await userEvent.type(screen.getByLabelText(/^Port$/), '8080') + + // Check Add Uptime + const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i) + await userEvent.click(addUptimeCheckbox) + + // Adjust uptime options — locate the container for the uptime inputs + const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i) + const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement + if (!uptimeContainer) throw new Error('Uptime container not found') + + const { within } = await import('@testing-library/react') + const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton') + // first spinbutton is interval, second is max retries + fireEvent.change(spinbuttons[0], { target: { value: '30' } }) + fireEvent.change(spinbuttons[1], { target: { value: '2' } }) + + // Submit + const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement + if (!submitBtn) throw new Error('Submit button not found') + await userEvent.click(submitBtn) + + // wait for onSubmit to have been called + await waitFor(() => expect(onSubmit).toHaveBeenCalled()) + + // Ensure uptime API was called with provided options + const uptime = await import('../../api/uptime') + await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 })) + + // Ensure onSubmit payload does not include temporary uptime keys + const onSubmitMock = onSubmit as unknown as import('vitest').Mock + const submittedPayload = onSubmitMock.mock.calls[0][0] + expect(submittedPayload).not.toHaveProperty('addUptime') + expect(submittedPayload).not.toHaveProperty('uptimeInterval') + expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries') + }) +}) diff --git a/frontend/src/components/__tests__/ProxyHostForm.test.tsx b/frontend/src/components/__tests__/ProxyHostForm.test.tsx index 62d7ae9b..7c54a8ea 100644 --- a/frontend/src/components/__tests__/ProxyHostForm.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event' import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import ProxyHostForm from '../ProxyHostForm' +import type { ProxyHost } from '../../api/proxyHosts' import { mockRemoteServers } from '../../test/mockData' // Mock the hooks @@ -445,7 +446,7 @@ describe('ProxyHostForm', () => { }) it('auto-populates advanced_config when selecting plex preset and field empty', async () => { - renderWithClient( + await renderWithClientAct( ) @@ -488,7 +489,7 @@ describe('ProxyHostForm', () => { } renderWithClient( - + ) // Select Plex preset (should prompt since advanced_config is non-empty) @@ -531,7 +532,7 @@ describe('ProxyHostForm', () => { } renderWithClient( - + ) // The restore button should be visible @@ -609,7 +610,7 @@ describe('ProxyHostForm', () => { }) it('does not show config helper when preset is none', async () => { - renderWithClient( + await renderWithClientAct( ) diff --git a/frontend/src/hooks/useProxyHosts.ts b/frontend/src/hooks/useProxyHosts.ts index cf9a9437..6ed447c9 100644 --- a/frontend/src/hooks/useProxyHosts.ts +++ b/frontend/src/hooks/useProxyHosts.ts @@ -34,7 +34,8 @@ export function useProxyHosts() { }); const deleteMutation = useMutation({ - mutationFn: (uuid: string) => deleteProxyHost(uuid), + mutationFn: (opts: { uuid: string; deleteUptime?: boolean } | string) => + typeof opts === 'string' ? deleteProxyHost(opts) : (opts.deleteUptime !== undefined ? deleteProxyHost(opts.uuid, opts.deleteUptime) : deleteProxyHost(opts.uuid)), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QUERY_KEY }); }, @@ -55,7 +56,7 @@ export function useProxyHosts() { error: query.error ? (query.error as Error).message : null, createHost: createMutation.mutateAsync, updateHost: (uuid: string, data: Partial) => updateMutation.mutateAsync({ uuid, data }), - deleteHost: deleteMutation.mutateAsync, + deleteHost: (uuid: string, deleteUptime?: boolean) => deleteMutation.mutateAsync(deleteUptime !== undefined ? { uuid, deleteUptime } : uuid), bulkUpdateACL: (hostUUIDs: string[], accessListID: number | null) => bulkUpdateACLMutation.mutateAsync({ hostUUIDs, accessListID }), isCreating: createMutation.isPending, diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 6a87512b..eac5a4c9 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -1,6 +1,6 @@ import { useState, type FC } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate } from '../api/notifications'; +import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, NotificationTemplate } from '../api/notifications'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react'; @@ -59,8 +59,9 @@ const ProviderForm: FC<{ const res = await previewProvider(formData as Partial); if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); } - } catch (err: any) { - setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setPreviewError(msg || 'Failed to generate preview'); } }; @@ -133,11 +134,11 @@ const ProviderForm: FC<{ @@ -244,8 +245,9 @@ const TemplateForm: FC<{ try { const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' }); if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered); - } catch (err: any) { - setPreviewErr(err?.response?.data?.error || err?.message || 'Preview failed'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setPreviewErr(msg || 'Preview failed'); } }; @@ -379,7 +381,7 @@ const Notifications: FC = () => { {editingTemplateId !== null && ( t.id === editingTemplateId) as Partial} + initialData={externalTemplates?.find((t: ExternalTemplate) => t.id === editingTemplateId) as Partial} onClose={() => setEditingTemplateId(null)} onSubmit={(data) => { if (editingTemplateId) updateTemplateMutation.mutate({ id: editingTemplateId, data }); diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 96048141..e7079e11 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react' import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown, CheckSquare, Square, Trash2 } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { useProxyHosts } from '../hooks/useProxyHosts' +import { getMonitors, type UptimeMonitor } from '../api/uptime' import { useCertificates } from '../hooks/useCertificates' import { useAccessLists } from '../hooks/useAccessLists' import { getSettings } from '../api/settings' @@ -12,6 +13,10 @@ import type { AccessList } from '../api/accessLists' import ProxyHostForm from '../components/ProxyHostForm' import { Switch } from '../components/ui/Switch' import { toast } from 'react-hot-toast' +import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers' + +// Helper functions extracted for unit testing and reuse +// Helpers moved to ../utils/proxyHostsHelpers to keep component files component-only for fast refresh type SortColumn = 'name' | 'domain' | 'forward' type SortDirection = 'asc' | 'desc' @@ -26,11 +31,20 @@ export default function ProxyHosts() { const [sortDirection, setSortDirection] = useState('asc') const [selectedHosts, setSelectedHosts] = useState>(new Set()) const [showBulkACLModal, setShowBulkACLModal] = useState(false) + const [showBulkApplyModal, setShowBulkApplyModal] = useState(false) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [isCreatingBackup, setIsCreatingBackup] = useState(false) const [selectedACLs, setSelectedACLs] = useState>(new Set()) const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply') const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null) + const [bulkApplySettings, setBulkApplySettings] = useState>({ + ssl_forced: { apply: false, value: true }, + http2_support: { apply: false, value: true }, + hsts_enabled: { apply: false, value: true }, + hsts_subdomains: { apply: false, value: true }, + block_exploits: { apply: false, value: true }, + websocket_support: { apply: false, value: true }, + }) const { data: settings } = useQuery({ queryKey: ['settings'], @@ -80,6 +94,12 @@ export default function ProxyHosts() { } } + + + // local usage now relies on the exported settingHelpText helper + + // local usage now relies on exported settingKeyToField helper + const handleAdd = () => { setEditingHost(undefined) setShowForm(true) @@ -101,12 +121,29 @@ export default function ProxyHosts() { } const handleDelete = async (uuid: string) => { - if (confirm('Are you sure you want to delete this proxy host?')) { + const host = hosts.find(h => h.uuid === uuid) + if (!host) return + + if (!confirm('Are you sure you want to delete this proxy host?')) return + + try { + // See if there are uptime monitors associated with this host (match by upstream_host / forward_host) + let associatedMonitors: UptimeMonitor[] = [] try { - await deleteHost(uuid) - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to delete') + const monitors = await getMonitors() + associatedMonitors = monitors.filter(m => m.upstream_host === host.forward_host || (m.proxy_host_id && m.proxy_host_id === (host as unknown as { id?: number }).id)) + } catch { + // ignore errors fetching uptime data; continue with host deletion } + + if (associatedMonitors.length > 0) { + const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?') + await deleteHost(uuid, deleteUptime) + } else { + await deleteHost(uuid) + } + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete') } } @@ -202,6 +239,12 @@ export default function ProxyHosts() { {selectedHosts.size} {selectedHosts.size === hosts.length && '(all)'} selected + + + + + + + )} +
{loading ? (
Loading...
@@ -242,11 +385,12 @@ export default function ProxyHosts() {
) : (
- +
- - - - - -
handleSort('name')} + style={{ width: '20%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
@@ -256,6 +400,7 @@ export default function ProxyHosts() {
handleSort('domain')} + style={{ width: '26%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
@@ -265,6 +410,7 @@ export default function ProxyHosts() {
handleSort('forward')} + style={{ width: '18%' }} className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors" >
@@ -272,16 +418,16 @@ export default function ProxyHosts() {
+ SSL + Status + Actions +
-
- {host.name || Unnamed} -
+
+ {host.name || Unnamed} +
-
- {host.domain_names.split(',').map((domain, i) => { - const url = `${host.ssl_forced ? 'https' : 'http'}://${domain.trim()}` - return ( - - ) - })} -
+
+ {host.domain_names.split(',').map((domain, i) => { + const d = domain.trim() + const url = `${host.ssl_forced ? 'https' : 'http'}://${d}` + return ( + + ) + })} +
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
+ {(() => { // Get the primary domain to look up cert status (case-insensitive) const primaryDomain = host.domain_names.split(',')[0]?.trim().toLowerCase() @@ -386,7 +535,7 @@ export default function ProxyHosts() { ) })()} +
{ - toast.error(`Failed to update flag: ${err?.message || err}`) + onError: (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + toast.error(`Failed to update flag: ${msg}`) }, }) @@ -119,13 +120,13 @@ export default function SystemSettings() { useEffect(() => { fetchCrowdsecStatus() }, []) - const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e:any) => toast.error(String(e)) }) - const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e:any) => toast.error(String(e)) }) + const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) }) + const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) }) const importMutation = useMutation({ mutationFn: async (file: File) => importCrowdsecConfig(file), onSuccess: () => { toast.success('CrowdSec config imported'); fetchCrowdsecStatus() }, - onError: (e:any) => toast.error(String(e)), + onError: (e: unknown) => toast.error(String(e)), }) const handleCrowdsecUpload = (e: React.ChangeEvent) => { diff --git a/frontend/src/pages/Uptime.tsx b/frontend/src/pages/Uptime.tsx index be83632e..3b239624 100644 --- a/frontend/src/pages/Uptime.tsx +++ b/frontend/src/pages/Uptime.tsx @@ -1,7 +1,8 @@ import { useMemo, useState, type FC, type FormEvent } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getMonitors, getMonitorHistory, updateMonitor, UptimeMonitor } from '../api/uptime'; -import { Activity, ArrowUp, ArrowDown, Settings, X } from 'lucide-react'; +import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, UptimeMonitor } from '../api/uptime'; +import { Activity, ArrowUp, ArrowDown, Settings, X, Pause } from 'lucide-react'; +import { toast } from 'react-hot-toast' import { formatDistanceToNow } from 'date-fns'; const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) => void }> = ({ monitor, onEdit }) => { @@ -11,27 +12,110 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) refetchInterval: 60000, }); - const isUp = monitor.status === 'up'; + const queryClient = useQueryClient() + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + return await deleteMonitor(id) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['monitors'] }) + toast.success('Monitor deleted') + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to delete monitor') + } + }) + + const toggleMutation = useMutation({ + mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { + return await updateMonitor(id, { enabled }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['monitors'] }) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to update monitor') + } + }) + + const [showMenu, setShowMenu] = useState(false) + + // Determine current status from most recent heartbeat when available + const latestBeat = history && history.length > 0 + ? history.reduce((a, b) => new Date(a.created_at) > new Date(b.created_at) ? a : b) + : null + + const isUp = latestBeat ? latestBeat.status === 'up' : monitor.status === 'up'; + const isPaused = monitor.enabled === false; return ( -
+
{/* Top Row: Name (left), Badge (center-right), Settings (right) */}
-

{monitor.name}

-
+

{monitor.name}

+
- {isUp ? : } - {monitor.status.toUpperCase()} + {isPaused ? : isUp ? : } + {isPaused ? 'PAUSED' : monitor.status.toUpperCase()} +
+
+ + + {showMenu && ( +
+ + + +
+ )}
-
@@ -61,7 +145,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */} -
+
{/* Fill with empty bars if not enough history to keep alignment right-aligned */} {Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
@@ -128,7 +212,10 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({ min="1" max="10" value={maxRetries} - onChange={(e) => setMaxRetries(parseInt(e.target.value))} + onChange={(e) => { + const v = parseInt(e.target.value) + setMaxRetries(Number.isNaN(v) ? 0 : v) + }} className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />

@@ -145,7 +232,10 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({ min="10" max="3600" value={interval} - onChange={(e) => setInterval(parseInt(e.target.value))} + onChange={(e) => { + const v = parseInt(e.target.value) + setInterval(Number.isNaN(v) ? 0 : v) + }} className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />

diff --git a/frontend/src/pages/__tests__/Login.test.tsx b/frontend/src/pages/__tests__/Login.test.tsx index a192e1a6..b20e9c47 100644 --- a/frontend/src/pages/__tests__/Login.test.tsx +++ b/frontend/src/pages/__tests__/Login.test.tsx @@ -1,80 +1,63 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter } from 'react-router-dom'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; -import Login from '../Login'; -import * as setupApi from '../../api/setup'; - -// Mock AuthContext so useAuth works in tests -vi.mock('../../hooks/useAuth', () => ({ - useAuth: () => ({ - login: vi.fn(), - logout: vi.fn(), - isAuthenticated: false, - isLoading: false, - user: null, - }), -})); - -// Mock API client -vi.mock('../../api/client', () => ({ - default: { - post: vi.fn().mockResolvedValue({ data: {} }), - get: vi.fn().mockResolvedValue({ data: {} }), - }, -})); - -// Mock react-router-dom -const mockNavigate = vi.fn(); +import { describe, it, expect, vi, beforeEach } from 'vitest' +// Mock react-router-dom useNavigate at module level +const mockNavigate = vi.fn() vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); + const actual = await vi.importActual('react-router-dom') return { ...actual, useNavigate: () => mockNavigate, - }; -}); + } +}) -// Mock the API module -vi.mock('../../api/setup', () => ({ - getSetupStatus: vi.fn(), - performSetup: vi.fn(), -})); +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import Login from '../Login' +import * as setupApi from '../../api/setup' +import client from '../../api/client' +import * as authHook from '../../hooks/useAuth' +import type { AuthContextType } from '../../context/AuthContextValue' +import { toast } from '../../utils/toast' +import { MemoryRouter } from 'react-router-dom' -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); +vi.mock('../../api/setup') +vi.mock('../../hooks/useAuth') -const renderWithProviders = (ui: React.ReactNode) => { - return render( - - - {ui} - - - ); -}; +describe('', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const renderWithProviders = (ui: React.ReactNode) => ( + render( + + {ui} + + ) + ) -describe('Login Page', () => { beforeEach(() => { - vi.clearAllMocks(); - queryClient.clear(); - }); + vi.restoreAllMocks() + vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: vi.fn() } as unknown as AuthContextType) + }) - it('renders login form and logo when setup is not required', async () => { - vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false }); - - renderWithProviders(); - - // The page will redirect to setup if setup is required; for our test we mock it as not required + it('navigates to /setup when setup is required', async () => { + vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: true }) + renderWithProviders() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Sign In' })).toBeTruthy(); - }); + expect(mockNavigate).toHaveBeenCalledWith('/setup') + }) + }) - // Verify logo is present - expect(screen.getAllByAltText('Charon').length).toBeGreaterThan(0); - }); -}); + it('shows error toast when login fails', async () => { + vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false }) + const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ response: { data: { error: 'Bad creds' } } }) + const toastSpy = vi.spyOn(toast, 'error') + renderWithProviders() + // Fill and submit + const email = screen.getByPlaceholderText(/admin@example.com/i) + const pass = screen.getByPlaceholderText(/••••••••/i) + fireEvent.change(email, { target: { value: 'a@b.com' } }) + fireEvent.change(pass, { target: { value: 'pw' } }) + fireEvent.click(screen.getByRole('button', { name: /Sign In/i })) + // Wait for the promise chain + await waitFor(() => expect(postSpy).toHaveBeenCalled()) + expect(toastSpy).toHaveBeenCalledWith('Bad creds') + }) +}) diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx new file mode 100644 index 00000000..40fceb5f --- /dev/null +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-all-settings.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import ProxyHosts from '../ProxyHosts'; +import * as proxyHostsApi from '../../api/proxyHosts'; +import * as certificatesApi from '../../api/certificates'; +import type { ProxyHost } from '../../api/proxyHosts' +import type { Certificate } from '../../api/certificates' +import * as accessListsApi from '../../api/accessLists'; +import type { AccessList } from '../../api/accessLists' +import * as settingsApi from '../../api/settings'; +import { createMockProxyHost } from '../../testUtils/createMockProxyHost'; + +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } })); +vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() })); +vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() })); +vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } })); +vi.mock('../../api/settings', () => ({ getSettings: vi.fn() })); + +const hosts = [ + createMockProxyHost({ uuid: 'h1', name: 'Host 1', domain_names: 'one.example.com' }), + createMockProxyHost({ uuid: 'h2', name: 'Host 2', domain_names: 'two.example.com' }), +]; + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient(); + return render( + + {ui} + + ); +}; + +describe('ProxyHosts - Bulk Apply all settings coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[]); + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]); + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]); + vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record); + }); + + it('renders all bulk apply setting labels and allows toggling', async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('Host 1')).toBeTruthy()); + + // select all + const headerCheckbox = screen.getAllByRole('checkbox')[0]; + await userEvent.click(headerCheckbox); + + // open Bulk Apply + await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy()); + await userEvent.click(screen.getByText('Bulk Apply')); + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()); + + const labels = [ + 'Force SSL', + 'HTTP/2 Support', + 'HSTS Enabled', + 'HSTS Subdomains', + 'Block Exploits', + 'Websockets Support', + ]; + + for (const lbl of labels) { + expect(screen.getByText(lbl)).toBeTruthy(); + // find close checkbox and click its apply checkbox (the first input in the label area) + const el = screen.getByText(lbl) as HTMLElement; + let container: HTMLElement | null = el; + while (container && !container.querySelector('input[type="checkbox"]')) container = container.parentElement; + const cb = container?.querySelector('input[type="checkbox"]') as HTMLElement | null; + if (cb) await userEvent.click(cb); + } + + // After toggling at least one, Apply should be enabled + const modalRoot = screen.getByText('Bulk Apply Settings').closest('div'); + const { within } = await import('@testing-library/react'); + const applyBtn = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i }); + expect(applyBtn).toBeTruthy(); + // Cancel to close + await userEvent.click(modalRoot ? within(modalRoot).getByRole('button', { name: /Cancel/i }) : screen.getByRole('button', { name: /Cancel/i })); + await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull()); + }); +}); diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx new file mode 100644 index 00000000..94933821 --- /dev/null +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply-progress.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import ProxyHosts from '../ProxyHosts' +import * as proxyHostsApi from '../../api/proxyHosts' +import * as certificatesApi from '../../api/certificates' +import * as accessListsApi from '../../api/accessLists' +import * as settingsApi from '../../api/settings' +import type { Certificate } from '../../api/certificates' +import type { AccessList } from '../../api/accessLists' +import { createMockProxyHost } from '../../testUtils/createMockProxyHost' +import type { ProxyHost } from '../../api/proxyHosts' + +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } })) +vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), createProxyHost: vi.fn(), updateProxyHost: vi.fn(), deleteProxyHost: vi.fn(), bulkUpdateACL: vi.fn(), testProxyHostConnection: vi.fn() })) +vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() })) +vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } })) +vi.mock('../../api/settings', () => ({ getSettings: vi.fn() })) + +const hosts = [ + createMockProxyHost({ uuid: 'p1', name: 'Progress 1', domain_names: 'p1.example.com' }), + createMockProxyHost({ uuid: 'p2', name: 'Progress 2', domain_names: 'p2.example.com' }), +] + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }) +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient() + return render( + + {ui} + + ) +} + +describe('ProxyHosts - Bulk Apply progress UI', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(hosts as ProxyHost[]) + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record) + }) + + it('shows applying progress while updateProxyHost resolves', async () => { + // Make updateProxyHost return controllable promises so we can assert the progress UI + const updateMock = vi.mocked(proxyHostsApi.updateProxyHost) + const resolvers: Array<(v: ProxyHost) => void> = [] + updateMock.mockImplementation(() => new Promise((res: (v: ProxyHost) => void) => { resolvers.push(res) })) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Progress 1')).toBeTruthy()) + + // Select all + const selectAll = screen.getAllByRole('checkbox')[0] + await userEvent.click(selectAll) + + // Open Bulk Apply + await userEvent.click(screen.getByText('Bulk Apply')) + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()) + + // Enable one setting (Force SSL) + const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement + let forceContainer: HTMLElement | null = forceLabel + while (forceContainer && !forceContainer.querySelector('input[type="checkbox"]')) forceContainer = forceContainer.parentElement + const forceCheckbox = forceContainer ? (forceContainer.querySelector('input[type="checkbox"]') as HTMLElement | null) : null + if (forceCheckbox) await userEvent.click(forceCheckbox as HTMLElement) + + // Click Apply and assert progress UI appears + const modalRoot = screen.getByText('Bulk Apply Settings').closest('div') + const { within } = await import('@testing-library/react') + const applyButton = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i }) + await userEvent.click(applyButton) + + // During the small delay the progress text should appear (there are two matching nodes) + await waitFor(() => expect(screen.getAllByText(/Applying settings/i).length).toBeGreaterThan(0)) + + // Resolve both pending update promises to finish the operation + resolvers.forEach(r => r(hosts[0])) + // Ensure subsequent tests aren't blocked by the special mock: make updateProxyHost resolve normally + updateMock.mockImplementation(() => Promise.resolve(hosts[0] as ProxyHost)) + + // Wait for updates to complete + await waitFor(() => expect(updateMock).toHaveBeenCalledTimes(2)) + }) +}) + +export {} diff --git a/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx new file mode 100644 index 00000000..2e3bbad6 --- /dev/null +++ b/frontend/src/pages/__tests__/ProxyHosts-bulk-apply.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import ProxyHosts from '../ProxyHosts'; +import * as proxyHostsApi from '../../api/proxyHosts'; +import * as certificatesApi from '../../api/certificates'; +import type { Certificate } from '../../api/certificates' +import type { ProxyHost } from '../../api/proxyHosts' +import * as accessListsApi from '../../api/accessLists'; +import type { AccessList } from '../../api/accessLists' +import * as settingsApi from '../../api/settings'; +import { createMockProxyHost } from '../../testUtils/createMockProxyHost'; + +// Mock toast +vi.mock('react-hot-toast', () => ({ + toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +vi.mock('../../api/proxyHosts', () => ({ + getProxyHosts: vi.fn(), + createProxyHost: vi.fn(), + updateProxyHost: vi.fn(), + deleteProxyHost: vi.fn(), + bulkUpdateACL: vi.fn(), + testProxyHostConnection: vi.fn(), +})); + +vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() })); +vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } })); +vi.mock('../../api/settings', () => ({ getSettings: vi.fn() })); + +const mockProxyHosts = [ + createMockProxyHost({ uuid: 'host-1', name: 'Test Host 1', domain_names: 'test1.example.com', forward_host: '192.168.1.10' }), + createMockProxyHost({ uuid: 'host-2', name: 'Test Host 2', domain_names: 'test2.example.com', forward_host: '192.168.1.20' }), +]; + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }); + +const renderWithProviders = (ui: React.ReactNode) => { + const queryClient = createQueryClient(); + return render( + + {ui} + + ); +}; + +describe('ProxyHosts - Bulk Apply Settings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue(mockProxyHosts as ProxyHost[]); + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([] as Certificate[]); + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([] as AccessList[]); + vi.mocked(settingsApi.getSettings).mockResolvedValue({} as Record); + }); + + it('shows Bulk Apply button when hosts selected and opens modal', async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy()); + + // Select first host using select-all checkbox + const selectAll = screen.getAllByRole('checkbox')[0]; + await userEvent.click(selectAll); + + // Bulk Apply button should appear + await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy()); + + // Open modal + await userEvent.click(screen.getByText('Bulk Apply')); + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()); + }); + + it('applies selected settings to all selected hosts by calling updateProxyHost merged payload', async () => { + const updateMock = vi.mocked(proxyHostsApi.updateProxyHost); + updateMock.mockResolvedValue(mockProxyHosts[0] as ProxyHost); + + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy()); + + // Select hosts + const selectAll = screen.getAllByRole('checkbox')[0]; + await userEvent.click(selectAll); + await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy()); + + // Open Bulk Apply modal + await userEvent.click(screen.getByText('Bulk Apply')); + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()); + + // Enable first setting checkbox (Force SSL) + // Enable first setting checkbox (Force SSL) - locate by text then find the checkbox inside its container + const forceLabel = screen.getByText(/Force SSL/i) as HTMLElement; + let forceContainer: HTMLElement | null = forceLabel; + while (forceContainer && !forceContainer.querySelector('input[type="checkbox"]')) { + forceContainer = forceContainer.parentElement + } + const forceCheckbox = forceContainer ? (forceContainer.querySelector('input[type="checkbox"]') as HTMLElement | null) : null; + if (forceCheckbox) await userEvent.click(forceCheckbox as HTMLElement); + + // Click Apply (scope to modal to avoid matching header 'Bulk Apply' button) + const modalRoot = screen.getByText('Bulk Apply Settings').closest('div'); + const { within } = await import('@testing-library/react'); + const applyButton = modalRoot ? within(modalRoot).getByRole('button', { name: /^Apply$/i }) : screen.getByRole('button', { name: /^Apply$/i }); + await userEvent.click(applyButton); + + // Should call updateProxyHost for each selected host with merged payload containing ssl_forced + await waitFor(() => { + expect(updateMock).toHaveBeenCalled(); + const calls = updateMock.mock.calls; + expect(calls.length).toBe(2); + expect(calls[0][1]).toHaveProperty('ssl_forced'); + expect(calls[1][1]).toHaveProperty('ssl_forced'); + }); + }); + + it('cancels bulk apply modal when Cancel clicked', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Test Host 1')).toBeTruthy()); + const selectAll = screen.getAllByRole('checkbox')[0]; + await userEvent.click(selectAll); + await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy()); + await userEvent.click(screen.getByText('Bulk Apply')); + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull()); + }); +}); diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx new file mode 100644 index 00000000..c1bef860 --- /dev/null +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { act } from 'react' +import type { ProxyHost } from '../../api/proxyHosts' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// We'll use per-test module mocks via `vi.doMock` and dynamic imports to avoid +// leaking mocks into other tests. Each test creates its own QueryClient. + +describe('ProxyHosts page - coverage targets (isolated)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const renderPage = async () => { + // Dynamic mocks + const mockUpdateHost = vi.fn() + + vi.doMock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } })) + + vi.doMock('../../hooks/useProxyHosts', () => ({ + useProxyHosts: vi.fn(() => ({ + hosts: [ + { + uuid: 'host-1', + name: 'StagingHost', + domain_names: 'staging.example.com', + forward_scheme: 'http', + forward_host: '10.0.0.1', + forward_port: 80, + ssl_forced: true, + websocket_support: true, + certificate: undefined, + enabled: true, + created_at: '2025-01-01', + updated_at: '2025-01-01', + }, + { + uuid: 'host-2', + name: 'CustomCertHost', + domain_names: 'custom.example.com', + forward_scheme: 'http', + forward_host: '10.0.0.2', + forward_port: 8080, + ssl_forced: false, + websocket_support: false, + certificate: { provider: 'custom', name: 'ACME-CUSTOM' }, + enabled: false, + created_at: '2025-01-01', + updated_at: '2025-01-01', + } + ], + loading: false, + isFetching: false, + error: null, + createHost: vi.fn(), + updateHost: (uuid: string, data: Partial) => mockUpdateHost(uuid, data), + deleteHost: vi.fn(), + bulkUpdateACL: vi.fn(), + isBulkUpdating: false, + })) + })) + + vi.doMock('../../hooks/useCertificates', () => ({ + useCertificates: vi.fn(() => ({ + certificates: [ + { id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } + ], + isLoading: false, + error: null, + })) + })) + + vi.doMock('../../hooks/useAccessLists', () => ({ useAccessLists: vi.fn(() => ({ data: [] })) })) + + vi.doMock('../../api/settings', () => ({ getSettings: vi.fn(() => Promise.resolve({ 'ui.domain_link_behavior': 'new_window' })) })) + + // Import page after mocks are in place + const { default: ProxyHosts } = await import('../ProxyHosts') + + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = (ui: React.ReactNode) => ( + {ui} + ) + + return { ProxyHosts, mockUpdateHost, wrapper } + } + + it('renders SSL staging badge, websocket badge and custom cert text', async () => { + const { ProxyHosts } = await renderPage() + + render( + + + + ) + + await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument()) + + expect(screen.getByText(/SSL \(Staging\)/)).toBeInTheDocument() + expect(screen.getByText('WS')).toBeInTheDocument() + expect(screen.getByText('ACME-CUSTOM (Custom)')).toBeInTheDocument() + }) + + it('opens domain link in new window when linkBehavior is new_window', async () => { + const { ProxyHosts } = await renderPage() + + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + render( + + + + ) + + await waitFor(() => expect(screen.getByText('staging.example.com')).toBeInTheDocument()) + const link = screen.getByText('staging.example.com').closest('a') as HTMLAnchorElement + await act(async () => { + await userEvent.click(link!) + }) + + expect(openSpy).toHaveBeenCalled() + openSpy.mockRestore() + }) + + it('bulk apply merges host data and calls updateHost', async () => { + const { ProxyHosts, mockUpdateHost } = await renderPage() + + render( + + + + ) + + await waitFor(() => expect(screen.getByText('StagingHost')).toBeInTheDocument()) + + const selectBtn1 = screen.getByLabelText('Select StagingHost') + const selectBtn2 = screen.getByLabelText('Select CustomCertHost') + await userEvent.click(selectBtn1) + await userEvent.click(selectBtn2) + + const bulkBtn = screen.getByText('Bulk Apply') + await userEvent.click(bulkBtn) + + const modal = screen.getByText('Bulk Apply Settings').closest('div')! + const modalWithin = within(modal) + + const checkboxes = modal.querySelectorAll('input[type="checkbox"]') + expect(checkboxes.length).toBeGreaterThan(0) + await userEvent.click(checkboxes[0]) + + const applyBtn = modalWithin.getByRole('button', { name: /Apply/ }) + await userEvent.click(applyBtn) + + await waitFor(() => { + expect(mockUpdateHost).toHaveBeenCalled() + }) + + const calls = vi.mocked(mockUpdateHost).mock.calls + expect(calls.length).toBeGreaterThanOrEqual(1) + const [calledUuid, calledData] = calls[0] + expect(typeof calledUuid).toBe('string') + expect(Object.prototype.hasOwnProperty.call(calledData, 'ssl_forced')).toBe(true) + }) +}) + +export {} diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx index da680637..365d0780 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx @@ -4,16 +4,20 @@ import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { vi, describe, it, expect, beforeEach } from 'vitest' +import type { ProxyHost } from '../../api/proxyHosts' import ProxyHosts from '../ProxyHosts' +import { formatSettingLabel, settingHelpText, settingKeyToField, applyBulkSettingsToHosts } from '../../utils/proxyHostsHelpers' import * as proxyHostsApi from '../../api/proxyHosts' import * as certificatesApi from '../../api/certificates' import * as accessListsApi from '../../api/accessLists' +import type { AccessList } from '../../api/accessLists' import * as settingsApi from '../../api/settings' +import * as uptimeApi from '../../api/uptime' +// Certificate type not required in this spec +import type { UptimeMonitor } from '../../api/uptime' // toast is mocked in other tests; not used here -vi.mock('react-hot-toast', () => ({ - toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, -})) +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } })) vi.mock('../../api/proxyHosts', () => ({ getProxyHosts: vi.fn(), @@ -28,6 +32,7 @@ vi.mock('../../api/certificates', () => ({ getCertificates: vi.fn() })) vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } })) vi.mock('../../api/settings', () => ({ getSettings: vi.fn() })) vi.mock('../../api/backups', () => ({ createBackup: vi.fn() })) +vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() })) const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } }) @@ -42,7 +47,7 @@ const renderWithProviders = (ui: React.ReactNode) => { import { createMockProxyHost } from '../../testUtils/createMockProxyHost' -const baseHost = (overrides: any = {}) => createMockProxyHost(overrides) +const baseHost = (overrides: Partial = {}) => createMockProxyHost(overrides) describe('ProxyHosts - Coverage enhancements', () => { beforeEach(() => vi.clearAllMocks()) @@ -79,7 +84,7 @@ describe('ProxyHosts - Coverage enhancements', () => { certificate: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - } as any) + } as ProxyHost) vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) @@ -151,7 +156,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bad things')) @@ -251,7 +256,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist', enabled: true, ip_rules: '[]', country_codes: '', local_network_only: false, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) renderWithProviders() @@ -275,7 +280,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) renderWithProviders() @@ -303,7 +308,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] }) @@ -331,7 +336,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) renderWithProviders() @@ -360,7 +365,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 1, errors: [{ uuid: 's2', error: 'Bad' }] }) @@ -386,7 +391,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(new Error('Bulk fail')) @@ -459,7 +464,7 @@ describe('ProxyHosts - Coverage enhancements', () => { }) it('renders SSL states: custom, staging, letsencrypt variations', async () => { - const hostCustom = baseHost({ uuid: 'c1', name: 'Custom', domain_names: 'custom.com', ssl_forced: true, certificate: { provider: 'custom', name: 'CustomCert' } }) + const hostCustom = baseHost({ uuid: 'c1', name: 'Custom', domain_names: 'custom.com', ssl_forced: true, certificate: { id: 123, uuid: 'cert-1', name: 'CustomCert', provider: 'custom', domains: 'custom.com', expires_at: '2026-01-01' } }) const hostStaging = baseHost({ uuid: 's1', name: 'Staging', domain_names: 'staging.com', ssl_forced: true }) const hostAuto = baseHost({ uuid: 'a1', name: 'Auto', domain_names: 'auto.com', ssl_forced: true }) const hostLets = baseHost({ uuid: 'l1', name: 'Lets', domain_names: 'lets.com', ssl_forced: true }) @@ -542,6 +547,87 @@ describe('ProxyHosts - Coverage enhancements', () => { confirmSpy.mockRestore() }) + it('deletes associated uptime monitors when confirmed', async () => { + const host = baseHost({ uuid: 'del2', name: 'Del2', forward_host: '127.0.0.5' }) + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host]) + vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue() + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({}) + // uptime monitors associated with host + vi.mocked(uptimeApi.getMonitors).mockResolvedValue([{ id: 'm1', name: 'm1', url: 'http://example', type: 'http', interval: 60, enabled: true, status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, upstream_host: '127.0.0.5' } as UptimeMonitor]) + + const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Del2')).toBeTruthy()) + const row = screen.getByText('Del2').closest('tr') as HTMLTableRowElement + const delButton = within(row).getByText('Delete') + await userEvent.click(delButton) + // Should call delete with deleteUptime true + await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del2', true)) + confirmSpy.mockRestore() + }) + + it('ignores uptime API errors and deletes host without deleting uptime', async () => { + const host = baseHost({ uuid: 'del3', name: 'Del3', forward_host: '127.0.0.6' }) + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host]) + vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue() + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({}) + // Make getMonitors throw + vi.mocked(uptimeApi.getMonitors).mockRejectedValue(new Error('OOPS')) + + const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true) + renderWithProviders() + await waitFor(() => expect(screen.getByText('Del3')).toBeTruthy()) + const row = screen.getByText('Del3').closest('tr') as HTMLTableRowElement + const delButton = within(row).getByText('Delete') + await userEvent.click(delButton) + // Should call delete without second param + await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('del3')) + confirmSpy.mockRestore() + }) + + it('applies bulk settings sequentially with progress and updates hosts', async () => { + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([ + baseHost({ uuid: 'host-1', name: 'H1' }), + baseHost({ uuid: 'host-2', name: 'H2' }), + ]) + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({}) + vi.mocked(proxyHostsApi.updateProxyHost).mockResolvedValue({} as ProxyHost) + + renderWithProviders() + await waitFor(() => expect(screen.getByText('H1')).toBeTruthy()) + + // Select both hosts + const headerCheckbox = screen.getAllByRole('checkbox')[0] + await userEvent.click(headerCheckbox) + + // Open Bulk Apply modal + await userEvent.click(screen.getByText('Bulk Apply')) + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()) + + // In the modal, find Force SSL row and enable apply and set value true + const forceLabel = screen.getByText('Force SSL') + const rowEl = forceLabel.closest('.p-2') as HTMLElement || forceLabel.closest('div') as HTMLElement + // Use within to find checkboxes within this row for robust selection + const rowCheckboxes = within(rowEl).getAllByRole('checkbox', { hidden: true }) + if (rowCheckboxes.length >= 1) await userEvent.click(rowCheckboxes[0]) + + // Click Apply in the modal (narrow to modal scope) + const modal = screen.getByText('Bulk Apply Settings').closest('div') as HTMLElement + const applyBtn = within(modal).getByRole('button', { name: /Apply/i }) + await userEvent.click(applyBtn) + + // Expect updateProxyHost called for each host with ssl_forced true included in payload + await waitFor(() => expect(proxyHostsApi.updateProxyHost).toHaveBeenCalledTimes(2)) + const calls = vi.mocked(proxyHostsApi.updateProxyHost).mock.calls + expect(calls.some(call => call[1] && (call[1] as Partial).ssl_forced === true)).toBeTruthy() + }) + it('shows Unnamed when name missing', async () => { const hostNoName = baseHost({ uuid: 'n1', name: '', domain_names: 'no-name.com' }) vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([hostNoName]) @@ -668,7 +754,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-a1', name: 'A1', description: 'A1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, { id: 2, uuid: 'acl-a2', name: 'A2', description: 'A2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({ updated: 2, errors: [] }) renderWithProviders() @@ -707,7 +793,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-1', name: 'List1', description: 'List 1', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, { id: 2, uuid: 'acl-2', name: 'List2', description: 'List 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) renderWithProviders() @@ -744,7 +830,7 @@ describe('ProxyHosts - Coverage enhancements', () => { vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([ { id: 1, uuid: 'acl-disable1', name: 'Disabled1', description: 'Disabled 1', type: 'blacklist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' }, { id: 2, uuid: 'acl-disable2', name: 'Disabled2', description: 'Disabled 2', type: 'whitelist' as const, ip_rules: '[]', country_codes: '', local_network_only: false, enabled: false, created_at: '2025-01-01', updated_at: '2025-01-01' }, - ] as any) + ] as AccessList[]) vi.mocked(settingsApi.getSettings).mockResolvedValue({}) renderWithProviders() @@ -758,6 +844,120 @@ describe('ProxyHosts - Coverage enhancements', () => { // Should show the 'No enabled access lists available' message await waitFor(() => expect(screen.getByText('No enabled access lists available')).toBeTruthy()) }) + + it('formatSettingLabel, settingHelpText and settingKeyToField return expected values and defaults', () => { + expect(formatSettingLabel('ssl_forced')).toBe('Force SSL') + expect(formatSettingLabel('http2_support')).toBe('HTTP/2 Support') + expect(formatSettingLabel('hsts_enabled')).toBe('HSTS Enabled') + expect(formatSettingLabel('hsts_subdomains')).toBe('HSTS Subdomains') + expect(formatSettingLabel('block_exploits')).toBe('Block Exploits') + expect(formatSettingLabel('websocket_support')).toBe('Websockets Support') + expect(formatSettingLabel('unknown_key')).toBe('unknown_key') + + expect(settingHelpText('ssl_forced')).toContain('Redirect all HTTP traffic') + expect(settingHelpText('http2_support')).toContain('Enable HTTP/2') + expect(settingHelpText('hsts_enabled')).toContain('Send HSTS header') + expect(settingHelpText('hsts_subdomains')).toContain('Include subdomains') + expect(settingHelpText('block_exploits')).toContain('Add common exploit-mitigation') + expect(settingHelpText('websocket_support')).toContain('Enable websocket proxying') + expect(settingHelpText('unknown_key')).toBe('') + + expect(settingKeyToField('ssl_forced')).toBe('ssl_forced') + expect(settingKeyToField('http2_support')).toBe('http2_support') + expect(settingKeyToField('hsts_enabled')).toBe('hsts_enabled') + expect(settingKeyToField('hsts_subdomains')).toBe('hsts_subdomains') + expect(settingKeyToField('block_exploits')).toBe('block_exploits') + expect(settingKeyToField('websocket_support')).toBe('websocket_support') + expect(settingKeyToField('unknown_key')).toBe('unknown_key') + }) + + it('closes bulk apply modal when clicking backdrop', async () => { + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([ + baseHost({ uuid: 's1', name: 'S1' }), + ]) + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({}) + + renderWithProviders() + await waitFor(() => expect(screen.getByText('S1')).toBeTruthy()) + const headerCheckbox = screen.getAllByRole('checkbox')[0] + const user = userEvent.setup() + await user.click(headerCheckbox) + await user.click(screen.getByText('Bulk Apply')) + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()) + // click backdrop + const overlay = document.querySelector('.fixed.inset-0') + if (overlay) await user.click(overlay) + await waitFor(() => expect(screen.queryByText('Bulk Apply Settings')).toBeNull()) + }) + + it('shows toast error when updateHost rejects during bulk apply', async () => { + const h1 = baseHost({ uuid: 'host-1', name: 'H1' }) + const h2 = baseHost({ uuid: 'host-2', name: 'H2' }) + vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([h1, h2]) + vi.mocked(certificatesApi.getCertificates).mockResolvedValue([]) + vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([]) + vi.mocked(settingsApi.getSettings).mockResolvedValue({}) + // mock updateProxyHost to fail for host-2 + vi.mocked(proxyHostsApi.updateProxyHost).mockImplementation(async (uuid: string) => { + if (uuid === 'host-2') throw new Error('update fail') + const result = baseHost({ uuid }) + return result + }) + + renderWithProviders() + await waitFor(() => expect(screen.getByText('H1')).toBeTruthy()) + // select both + const headerCheckbox = screen.getAllByRole('checkbox')[0] + const user = userEvent.setup() + await user.click(headerCheckbox) + // Open Bulk Apply + await user.click(screen.getByText('Bulk Apply')) + await waitFor(() => expect(screen.getByText('Bulk Apply Settings')).toBeTruthy()) + // enable Force SSL apply + set switch + const forceLabel = screen.getByText('Force SSL') + const rowEl = forceLabel.closest('.p-2') as HTMLElement || forceLabel.closest('div') as HTMLElement + // click apply checkbox and toggle switch reliably + const rowChecks = within(rowEl).getAllByRole('checkbox', { hidden: true }) + if (rowChecks[0]) await user.click(rowChecks[0]) + if (rowChecks[1]) await user.click(rowChecks[1]) + // click Apply + const modal = screen.getByText('Bulk Apply Settings').closest('div') as HTMLElement + const applyBtn = within(modal).getByRole('button', { name: /Apply/i }) + await user.click(applyBtn) + + const toast = (await import('react-hot-toast')).toast + await waitFor(() => expect(toast.error).toHaveBeenCalled()) + }) + + it('applyBulkSettingsToHosts returns error when host is not found and reports progress', async () => { + const hosts: ProxyHost[] = [] // no hosts + const hostUUIDs = ['missing-1'] + const keysToApply = ['ssl_forced'] + const bulkApplySettings: Record = { ssl_forced: { apply: true, value: true } } + const updateHost = vi.fn().mockResolvedValue({}) + const setApplyProgress = vi.fn() + + const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress }) + expect(result.errors).toBe(1) + expect(setApplyProgress).toHaveBeenCalled() + expect(updateHost).not.toHaveBeenCalled() + }) + + it('applyBulkSettingsToHosts handles updateHost rejection and counts errors', async () => { + const h1 = baseHost({ uuid: 'h1', name: 'H1' }) + const hosts = [h1] + const hostUUIDs = ['h1'] + const keysToApply = ['ssl_forced'] + const bulkApplySettings: Record = { ssl_forced: { apply: true, value: true } } + const updateHost = vi.fn().mockRejectedValue(new Error('fail')) + const setApplyProgress = vi.fn() + + const result = await applyBulkSettingsToHosts({ hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress }) + expect(result.errors).toBe(1) + expect(updateHost).toHaveBeenCalled() + }) }) export {} diff --git a/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx index 2c513523..4a0b1641 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-progress.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { vi, describe, it, expect, beforeEach } from 'vitest' +import type { ProxyHost, BulkUpdateACLResponse } from '../../api/proxyHosts' import ProxyHosts from '../ProxyHosts' import * as proxyHostsApi from '../../api/proxyHosts' import * as certificatesApi from '../../api/certificates' @@ -37,7 +38,7 @@ const renderWithProviders = (ui: React.ReactNode) => { ) } -const baseHost = (overrides: any = {}) => ({ +const baseHost = (overrides: Partial = {}): ProxyHost => ({ uuid: 'host-1', name: 'Host', domain_names: 'example.com', @@ -47,7 +48,17 @@ const baseHost = (overrides: any = {}) => ({ enabled: true, ssl_forced: false, websocket_support: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + block_exploits: false, + application: 'none', + locations: [], certificate: null, + certificate_id: null, + access_list_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), ...overrides, }) @@ -68,9 +79,11 @@ describe('ProxyHosts progress apply', () => { vi.mocked(settingsApi.getSettings).mockResolvedValue({}) // Create controllable promises for bulkUpdateACL invocations - const resolvers: Array<(value?: any) => void> = [] - vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((_hostUUIDs, _aclId) => { - return new Promise((resolve) => { resolvers.push(resolve) }) + const resolvers: Array<(value: BulkUpdateACLResponse) => void> = [] + vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation((...args: unknown[]) => { + const [_hostUUIDs, _aclId] = args + void _hostUUIDs; void _aclId + return new Promise((resolve: (v: BulkUpdateACLResponse) => void) => { resolvers.push(resolve); }) }) renderWithProviders() diff --git a/frontend/src/pages/__tests__/Uptime.spec.tsx b/frontend/src/pages/__tests__/Uptime.spec.tsx new file mode 100644 index 00000000..a23e0351 --- /dev/null +++ b/frontend/src/pages/__tests__/Uptime.spec.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import Uptime from '../Uptime' +import * as uptimeApi from '../../api/uptime' + +vi.mock('react-hot-toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() } })) +vi.mock('../../api/uptime') + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + +const renderWithProviders = (ui: React.ReactNode) => { + const qc = createQueryClient() + return render( + + {ui} + + ) +} + +describe('Uptime page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders no monitors message', async () => { + vi.mocked(uptimeApi.getMonitors).mockResolvedValue([]) + renderWithProviders() + expect(await screen.findByText(/No monitors found/i)).toBeTruthy() + }) + + it('calls updateMonitor when toggling monitoring', async () => { + const monitor = { + id: 'm1', name: 'Test Monitor', url: 'http://example.com', type: 'http', interval: 60, enabled: true, + status: 'up', last_check: new Date().toISOString(), latency: 10, max_retries: 3, proxy_host_id: 1, + } + vi.mocked(uptimeApi.getMonitors).mockResolvedValue([monitor]) + vi.mocked(uptimeApi.getMonitorHistory).mockResolvedValue([]) + vi.mocked(uptimeApi.updateMonitor).mockResolvedValue({ ...monitor, enabled: false }) + + renderWithProviders() + await waitFor(() => expect(screen.getByText('Test Monitor')).toBeInTheDocument()) + const card = screen.getByText('Test Monitor').closest('div') as HTMLElement + const settingsBtn = within(card).getByTitle('Monitor settings') + await userEvent.click(settingsBtn) + const toggleBtn = within(card).getByText('Disable Monitoring') + await userEvent.click(toggleBtn) + await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false })) + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index a18be778..0d41a58c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,3 +1,9 @@ +// Ensure React's act environment flag is set for React 18+ to avoid warnings +// This must be set before importing testing utilities. +// See: https://github.com/facebook/react/issues/24560#issuecomment-1021997243 +declare global { var IS_REACT_ACT_ENVIRONMENT: boolean | undefined } +globalThis.IS_REACT_ACT_ENVIRONMENT = true + import '@testing-library/jest-dom' import { cleanup } from '@testing-library/react' import { afterEach } from 'vitest' @@ -21,3 +27,23 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: () => {}, }), }) + +// Filter noisy React act environment warnings that can appear in some environments +const _origConsoleError = console.error +console.error = (...args: unknown[]) => { + try { + const msg = args[0] + if (typeof msg === 'string') { + if ( + msg.includes("The current testing environment is not configured to support act(") || + msg.includes('Test connection failed') || + msg.includes('Connection failed') + ) { + return + } + } + } catch { + // fallthrough to original + } + _origConsoleError.apply(console, args) +} diff --git a/frontend/src/utils/__tests__/compareHosts.test.ts b/frontend/src/utils/__tests__/compareHosts.test.ts index 777284e5..06f2cae7 100644 --- a/frontend/src/utils/__tests__/compareHosts.test.ts +++ b/frontend/src/utils/__tests__/compareHosts.test.ts @@ -46,7 +46,8 @@ const hostB: ProxyHost = { describe('compareHosts', () => { it('returns 0 for unknown sort column (default case)', () => { - const res = compareHosts(hostA, hostB, 'unknown' as any, 'asc') + const compareAny = compareHosts as unknown as (a: ProxyHost, b: ProxyHost, sortColumn: string, sortDirection: 'asc' | 'desc') => number + const res = compareAny(hostA, hostB, 'unknown', 'asc') expect(res).toBe(0) }) diff --git a/frontend/src/utils/__tests__/toast.test.ts b/frontend/src/utils/__tests__/toast.test.ts new file mode 100644 index 00000000..0dee4d67 --- /dev/null +++ b/frontend/src/utils/__tests__/toast.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast, toastCallbacks } from '../toast' + +describe('toast util', () => { + beforeEach(() => { + // Ensure callbacks set is empty before each test + toastCallbacks.clear() + }) + + afterEach(() => { + toastCallbacks.clear() + }) + + it('calls registered callbacks for each toast type', () => { + const mock = vi.fn() + toastCallbacks.add(mock) + + toast.success('ok') + toast.error('bad') + toast.info('info') + toast.warning('warn') + + expect(mock).toHaveBeenCalledTimes(4) + expect(mock.mock.calls[0][0]).toMatchObject({ message: 'ok', type: 'success' }) + expect(mock.mock.calls[1][0]).toMatchObject({ message: 'bad', type: 'error' }) + expect(mock.mock.calls[2][0]).toMatchObject({ message: 'info', type: 'info' }) + expect(mock.mock.calls[3][0]).toMatchObject({ message: 'warn', type: 'warning' }) + }) + + it('provides incrementing ids', () => { + const mock = vi.fn() + toastCallbacks.add(mock) + // send multiple messages + toast.success('one') + toast.success('two') + const firstId = mock.mock.calls[0][0].id + const secondId = mock.mock.calls[1][0].id + expect(secondId).toBeGreaterThan(firstId) + }) +}) diff --git a/frontend/src/utils/proxyHostsHelpers.ts b/frontend/src/utils/proxyHostsHelpers.ts new file mode 100644 index 00000000..3b0ac0ce --- /dev/null +++ b/frontend/src/utils/proxyHostsHelpers.ts @@ -0,0 +1,103 @@ +import type { ProxyHost } from '../api/proxyHosts' + +export function formatSettingLabel(key: string) { + switch (key) { + case 'ssl_forced': + return 'Force SSL' + case 'http2_support': + return 'HTTP/2 Support' + case 'hsts_enabled': + return 'HSTS Enabled' + case 'hsts_subdomains': + return 'HSTS Subdomains' + case 'block_exploits': + return 'Block Exploits' + case 'websocket_support': + return 'Websockets Support' + default: + return key + } +} + +export function settingHelpText(key: string) { + switch (key) { + case 'ssl_forced': + return 'Redirect all HTTP traffic to HTTPS.' + case 'http2_support': + return 'Enable HTTP/2 for improved performance.' + case 'hsts_enabled': + return 'Send HSTS header to enforce HTTPS.' + case 'hsts_subdomains': + return 'Include subdomains in HSTS policy.' + case 'block_exploits': + return 'Add common exploit-mitigation headers and rules.' + case 'websocket_support': + return 'Enable websocket proxying support.' + default: + return '' + } +} + +export function settingKeyToField(key: string) { + switch (key) { + case 'ssl_forced': + return 'ssl_forced' + case 'http2_support': + return 'http2_support' + case 'hsts_enabled': + return 'hsts_enabled' + case 'hsts_subdomains': + return 'hsts_subdomains' + case 'block_exploits': + return 'block_exploits' + case 'websocket_support': + return 'websocket_support' + default: + return key + } +} + +export async function applyBulkSettingsToHosts(options: { + hosts: ProxyHost[] + hostUUIDs: string[] + keysToApply: string[] + bulkApplySettings: Record + updateHost: (uuid: string, data: Partial) => Promise + setApplyProgress?: (p: { current: number; total: number } | null) => void +}) { + const { hosts, hostUUIDs, keysToApply, bulkApplySettings, updateHost, setApplyProgress } = options + let completed = 0 + let errors = 0 + setApplyProgress?.({ current: 0, total: hostUUIDs.length }) + + for (const uuid of hostUUIDs) { + const patch: Partial = {} + for (const key of keysToApply) { + const field = settingKeyToField(key) as keyof ProxyHost + ;(patch as unknown as Record)[field as string] = bulkApplySettings[key].value + } + + const host = hosts.find(h => h.uuid === uuid) + if (!host) { + errors++ + completed++ + setApplyProgress?.({ current: completed, total: hostUUIDs.length }) + continue + } + + const merged: Partial = { ...host, ...patch } + try { + await updateHost(uuid, merged) + } catch { + errors++ + } + + completed++ + setApplyProgress?.({ current: completed, total: hostUUIDs.length }) + } + + setApplyProgress?.(null) + return { errors, completed } +} + +export default {}