diff --git a/backend/go.mod b/backend/go.mod index 5f61aaa0..791957e6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,8 +6,10 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.45.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -34,7 +36,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.20.0 // indirect @@ -42,6 +43,5 @@ require ( golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.9 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go new file mode 100644 index 00000000..ac23901b --- /dev/null +++ b/backend/internal/api/handlers/auth_handler_test.go @@ -0,0 +1,68 @@ +package handlers + +import ( +"bytes" +"encoding/json" +"net/http" +"net/http/httptest" +"testing" + +"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" +"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" +"github.com/gin-gonic/gin" +"github.com/stretchr/testify/assert" +"github.com/stretchr/testify/require" +"gorm.io/driver/sqlite" +"gorm.io/gorm" +) + +func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) { +db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) +require.NoError(t, err) +db.AutoMigrate(&models.User{}, &models.Setting{}) + +cfg := config.Config{JWTSecret: "test-secret"} +authService := services.NewAuthService(db, cfg) +return NewAuthHandler(authService), db +} + +func TestAuthHandler_Login(t *testing.T) { +handler, db := setupAuthHandler(t) + +// Create user +user := &models.User{ +Email: "test@example.com", +Name: "Test User", +} +user.SetPassword("password123") +db.Create(user) + +gin.SetMode(gin.TestMode) +r := gin.New() +r.POST("/login", handler.Login) + +// Success +body := map[string]string{ +"email": "test@example.com", +"password": "password123", +} +jsonBody, _ := json.Marshal(body) +req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody)) +req.Header.Set("Content-Type", "application/json") +w := httptest.NewRecorder() +r.ServeHTTP(w, req) + +assert.Equal(t, http.StatusOK, w.Code) +assert.Contains(t, w.Body.String(), "token") + +// Failure +body["password"] = "wrong" +jsonBody, _ = json.Marshal(body) +req, _ = http.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody)) +req.Header.Set("Content-Type", "application/json") +w = httptest.NewRecorder() +r.ServeHTTP(w, req) + +assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/backend/internal/api/handlers/health_handler_test.go b/backend/internal/api/handlers/health_handler_test.go new file mode 100644 index 00000000..6037d12b --- /dev/null +++ b/backend/internal/api/handlers/health_handler_test.go @@ -0,0 +1,29 @@ +package handlers + +import ( +"encoding/json" +"net/http" +"net/http/httptest" +"testing" + +"github.com/gin-gonic/gin" +"github.com/stretchr/testify/assert" +) + +func TestHealthHandler(t *testing.T) { +gin.SetMode(gin.TestMode) +r := gin.New() +r.GET("/health", HealthHandler) + +req, _ := http.NewRequest("GET", "/health", nil) +w := httptest.NewRecorder() +r.ServeHTTP(w, req) + +assert.Equal(t, http.StatusOK, w.Code) + +var resp map[string]string +err := json.Unmarshal(w.Body.Bytes(), &resp) +assert.NoError(t, err) +assert.Equal(t, "ok", resp["status"]) +assert.NotEmpty(t, resp["version"]) +} diff --git a/backend/internal/api/handlers/notification_handler.go b/backend/internal/api/handlers/notification_handler.go new file mode 100644 index 00000000..5ea42eb1 --- /dev/null +++ b/backend/internal/api/handlers/notification_handler.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type NotificationHandler struct { + service *services.NotificationService +} + +func NewNotificationHandler(service *services.NotificationService) *NotificationHandler { + return &NotificationHandler{service: service} +} + +func (h *NotificationHandler) List(c *gin.Context) { + unreadOnly := c.Query("unread") == "true" + notifications, err := h.service.List(unreadOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"}) + return + } + c.JSON(http.StatusOK, notifications) +} + +func (h *NotificationHandler) MarkAsRead(c *gin.Context) { + id := c.Param("id") + if err := h.service.MarkAsRead(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"}) +} + +func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { + if err := h.service.MarkAllAsRead(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"}) +} diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go new file mode 100644 index 00000000..1f3d3787 --- /dev/null +++ b/backend/internal/api/handlers/settings_handler.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +type SettingsHandler struct { + DB *gorm.DB +} + +func NewSettingsHandler(db *gorm.DB) *SettingsHandler { + return &SettingsHandler{DB: db} +} + +// GetSettings returns all settings. +func (h *SettingsHandler) GetSettings(c *gin.Context) { + var settings []models.Setting + if err := h.DB.Find(&settings).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"}) + return + } + + // Convert to map for easier frontend consumption + settingsMap := make(map[string]string) + for _, s := range settings { + settingsMap[s.Key] = s.Value + } + + c.JSON(http.StatusOK, settingsMap) +} + +type UpdateSettingRequest struct { + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` + Category string `json:"category"` + Type string `json:"type"` +} + +// UpdateSetting updates or creates a setting. +func (h *SettingsHandler) UpdateSetting(c *gin.Context) { + var req UpdateSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + setting := models.Setting{ + Key: req.Key, + Value: req.Value, + } + + if req.Category != "" { + setting.Category = req.Category + } + if req.Type != "" { + setting.Type = req.Type + } + + // Upsert + if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"}) + return + } + + c.JSON(http.StatusOK, setting) +} diff --git a/backend/internal/api/handlers/update_handler.go b/backend/internal/api/handlers/update_handler.go new file mode 100644 index 00000000..8e1aac90 --- /dev/null +++ b/backend/internal/api/handlers/update_handler.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type UpdateHandler struct { + service *services.UpdateService +} + +func NewUpdateHandler(service *services.UpdateService) *UpdateHandler { + return &UpdateHandler{service: service} +} + +func (h *UpdateHandler) Check(c *gin.Context) { + info, err := h.service.CheckForUpdates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"}) + return + } + c.JSON(http.StatusOK, info) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index c7a7473b..1c2ccda8 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -21,6 +21,8 @@ func NewUserHandler(db *gorm.DB) *UserHandler { func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { r.GET("/setup", h.GetSetupStatus) r.POST("/setup", h.Setup) + r.GET("/profile", h.GetProfile) + r.POST("/regenerate-api-key", h.RegenerateAPIKey) } // GetSetupStatus checks if the application needs initial setup (i.e., no users exist). @@ -111,3 +113,44 @@ func (h *UserHandler) Setup(c *gin.Context) { }, }) } + +// RegenerateAPIKey generates a new API key for the authenticated user. +func (h *UserHandler) RegenerateAPIKey(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + apiKey := uuid.New().String() + + if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"}) + return + } + + c.JSON(http.StatusOK, gin.H{"api_key": apiKey}) +} + +// GetProfile returns the current user's profile including API key. +func (h *UserHandler) GetProfile(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "api_key": user.APIKey, + }) +} diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go new file mode 100644 index 00000000..3166c3ab --- /dev/null +++ b/backend/internal/api/handlers/user_handler_test.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) { + // Use unique DB for each test to avoid pollution + dbName := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.User{}, &models.Setting{}) + return NewUserHandler(db), db +} + +func TestUserHandler_GetSetupStatus(t *testing.T) { + handler, db := setupUserHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/setup", handler.GetSetupStatus) + + // No users -> setup required + req, _ := http.NewRequest("GET", "/setup", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "\"setupRequired\":true") + + // Create user -> setup not required + db.Create(&models.User{Email: "test@example.com"}) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "\"setupRequired\":false") +} + +func TestUserHandler_Setup(t *testing.T) { + handler, _ := setupUserHandler(t) + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/setup", handler.Setup) + + body := map[string]string{ + "name": "Admin", + "email": "admin@example.com", + "password": "password123", + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), "Setup completed successfully") + + // Try again -> should fail (already setup) + w = httptest.NewRecorder() + req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/backend/internal/api/middleware/auth_test.go b/backend/internal/api/middleware/auth_test.go new file mode 100644 index 00000000..f66e6a35 --- /dev/null +++ b/backend/internal/api/middleware/auth_test.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestAuthMiddleware_MissingHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + // We pass nil for authService because we expect it to fail before using it + r.Use(AuthMiddleware(nil)) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "Authorization header required") +} + +func TestRequireRole_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + r.Use(RequireRole("admin")) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRequireRole_Forbidden(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + r.Use(RequireRole("admin")) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 78db0917..d7041624 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -2,6 +2,7 @@ package routes import ( "fmt" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -26,6 +27,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.User{}, &models.Setting{}, &models.ImportSession{}, + &models.Notification{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -68,6 +70,45 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/logs", logsHandler.List) protected.GET("/logs/:filename", logsHandler.Read) + // Settings + settingsHandler := handlers.NewSettingsHandler(db) + protected.GET("/settings", settingsHandler.GetSettings) + protected.POST("/settings", settingsHandler.UpdateSetting) + + // User Profile & API Key + userHandler := handlers.NewUserHandler(db) + protected.GET("/user/profile", userHandler.GetProfile) + protected.POST("/user/api-key", userHandler.RegenerateAPIKey) + + // Updates + updateService := services.NewUpdateService() + updateHandler := handlers.NewUpdateHandler(updateService) + protected.GET("/system/updates", updateHandler.Check) + + // Notifications + notificationService := services.NewNotificationService(db) + notificationHandler := handlers.NewNotificationHandler(notificationService) + protected.GET("/notifications", notificationHandler.List) + protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead) + protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead) + + // Uptime Service + uptimeService := services.NewUptimeService(db, notificationService) + + // Start background checker (every 5 minutes) + go func() { + // Wait a bit for server to start + time.Sleep(1 * time.Minute) + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + uptimeService.CheckAllHosts() + } + }() + + protected.POST("/system/uptime/check", func(c *gin.Context) { + go uptimeService.CheckAllHosts() + c.JSON(200, gin.H{"message": "Uptime check started"}) + }) } proxyHostHandler := handlers.NewProxyHostHandler(db) diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go new file mode 100644 index 00000000..0bd5a21b --- /dev/null +++ b/backend/internal/api/routes/routes_test.go @@ -0,0 +1,41 @@ +package routes + +import ( +"testing" + +"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" +"github.com/gin-gonic/gin" +"github.com/stretchr/testify/assert" +"github.com/stretchr/testify/require" +"gorm.io/driver/sqlite" +"gorm.io/gorm" +) + +func TestRegister(t *testing.T) { +gin.SetMode(gin.TestMode) +router := gin.New() + +// Use in-memory DB +db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) +require.NoError(t, err) + +cfg := config.Config{ +JWTSecret: "test-secret", +} + +err = Register(router, db, cfg) +assert.NoError(t, err) + +// Verify some routes are registered +routes := router.Routes() +assert.NotEmpty(t, routes) + +foundHealth := false +for _, r := range routes { +if r.Path == "/api/v1/health" { +foundHealth = true +break +} +} +assert.True(t, foundHealth, "Health route should be registered") +} diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index cef0cb20..9ab24d04 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -21,6 +21,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin Logging: &LoggingConfig{ Logs: map[string]*LogConfig{ "access": { + Level: "INFO", Writer: &WriterConfig{ Output: "file", Filename: logFile, diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index e537504f..5600db94 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -113,3 +113,82 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "empty domain") } + +func TestGenerateConfig_Logging(t *testing.T) { + hosts := []models.ProxyHost{} + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") + require.NoError(t, err) + + // Verify logging config + require.NotNil(t, config.Logging) + require.NotNil(t, config.Logging.Logs) + require.Contains(t, config.Logging.Logs, "access") + + logConfig := config.Logging.Logs["access"] + require.Equal(t, "INFO", logConfig.Level) + require.NotNil(t, logConfig.Writer) + require.Equal(t, "file", logConfig.Writer.Output) + require.Contains(t, logConfig.Writer.Filename, "access.log") + require.NotNil(t, logConfig.Writer.RollSize) + require.NotNil(t, logConfig.Writer.RollKeep) +} + +func TestGenerateConfig_Advanced(t *testing.T) { + hosts := []models.ProxyHost{ + { + UUID: "advanced-uuid", + Name: "Advanced", + DomainNames: "advanced.example.com", + ForwardScheme: "http", + ForwardHost: "advanced", + ForwardPort: 8080, + SSLForced: true, + HSTSEnabled: true, + HSTSSubdomains: true, + BlockExploits: true, + Enabled: true, + Locations: []models.Location{ + { + Path: "/api", + ForwardHost: "api-service", + ForwardPort: 9000, + }, + }, + }, + } + + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com") + require.NoError(t, err) + require.NotNil(t, config) + + server := config.Apps.HTTP.Servers["cpm_server"] + require.NotNil(t, server) + // Should have 2 routes: 1 for location /api, 1 for main domain + require.Len(t, server.Routes, 2) + + // Check Location Route (should be first as it is more specific) + locRoute := server.Routes[0] + require.Equal(t, []string{"/api", "/api/*"}, locRoute.Match[0].Path) + require.Equal(t, []string{"advanced.example.com"}, locRoute.Match[0].Host) + + // Check Main Route + mainRoute := server.Routes[1] + require.Nil(t, mainRoute.Match[0].Path) // No path means all paths + require.Equal(t, []string{"advanced.example.com"}, mainRoute.Match[0].Host) + + // Check HSTS and BlockExploits handlers in main route + // Handlers are: [HSTS, BlockExploits, ReverseProxy] + // But wait, BlockExploitsHandler implementation details? + // Let's just check count for now or inspect types if possible. + // Based on code: + // handlers = append(handlers, HeaderHandler(...)) // HSTS + // handlers = append(handlers, BlockExploitsHandler()) // BlockExploits + // mainHandlers = append(handlers, ReverseProxyHandler(...)) + + require.Len(t, mainRoute.Handle, 3) + + // Check HSTS + hstsHandler := mainRoute.Handle[0] + require.Equal(t, "headers", hstsHandler["handler"]) + // We can't easily check the map content without casting, but we know it's there. +} diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go new file mode 100644 index 00000000..45251f02 --- /dev/null +++ b/backend/internal/caddy/manager_test.go @@ -0,0 +1,54 @@ +package caddy + +import ( +"context" +"encoding/json" +"net/http" +"net/http/httptest" +"testing" + +"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +"github.com/stretchr/testify/assert" +"github.com/stretchr/testify/require" +"gorm.io/driver/sqlite" +"gorm.io/gorm" +) + +func TestManager_ApplyConfig(t *testing.T) { +// Mock Caddy Admin API +caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +if r.URL.Path == "/load" && r.Method == "POST" { +// Verify payload +var config Config +err := json.NewDecoder(r.Body).Decode(&config) +if err != nil { +w.WriteHeader(http.StatusBadRequest) +return +} +w.WriteHeader(http.StatusOK) +return +} +w.WriteHeader(http.StatusNotFound) +})) +defer caddyServer.Close() + +// Setup DB +db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) +require.NoError(t, err) +db.AutoMigrate(&models.ProxyHost{}, &models.Setting{}) + +// Seed DB +db.Create(&models.ProxyHost{ +UUID: "test-uuid", +DomainNames: "example.com", +ForwardHost: "localhost", +ForwardPort: 8080, +Enabled: true, +}) + +client := NewClient(caddyServer.URL) +manager := NewManager(client, db, t.TempDir()) + +err = manager.ApplyConfig(context.Background()) +assert.NoError(t, err) +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 00000000..4021131a --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad(t *testing.T) { + // Save original env vars + originalEnv := os.Getenv("CPM_ENV") + defer os.Setenv("CPM_ENV", originalEnv) + + // Set test env vars + os.Setenv("CPM_ENV", "test") + tempDir := t.TempDir() + os.Setenv("CPM_DB_PATH", filepath.Join(tempDir, "test.db")) + os.Setenv("CPM_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy")) + os.Setenv("CPM_IMPORT_DIR", filepath.Join(tempDir, "imports")) + + cfg, err := Load() + require.NoError(t, err) + + assert.Equal(t, "test", cfg.Environment) + assert.Equal(t, filepath.Join(tempDir, "test.db"), cfg.DatabasePath) + assert.DirExists(t, filepath.Dir(cfg.DatabasePath)) + assert.DirExists(t, cfg.CaddyConfigDir) + assert.DirExists(t, cfg.ImportDir) +} + +func TestLoad_Defaults(t *testing.T) { + // Clear env vars to test defaults + os.Unsetenv("CPM_ENV") + os.Unsetenv("CPM_HTTP_PORT") + // We need to set paths to a temp dir to avoid creating real dirs in test + tempDir := t.TempDir() + os.Setenv("CPM_DB_PATH", filepath.Join(tempDir, "default.db")) + os.Setenv("CPM_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy_default")) + os.Setenv("CPM_IMPORT_DIR", filepath.Join(tempDir, "imports_default")) + + cfg, err := Load() + require.NoError(t, err) + + assert.Equal(t, "development", cfg.Environment) + assert.Equal(t, "8080", cfg.HTTPPort) +} diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go new file mode 100644 index 00000000..67323c74 --- /dev/null +++ b/backend/internal/database/database_test.go @@ -0,0 +1,22 @@ +package database + +import ( +"path/filepath" +"testing" + +"github.com/stretchr/testify/assert" +) + +func TestConnect(t *testing.T) { +// Test with memory DB +db, err := Connect("file::memory:?cache=shared") +assert.NoError(t, err) +assert.NotNil(t, db) + +// Test with file DB +tempDir := t.TempDir() +dbPath := filepath.Join(tempDir, "test.db") +db, err = Connect(dbPath) +assert.NoError(t, err) +assert.NotNil(t, db) +} diff --git a/backend/internal/models/notification.go b/backend/internal/models/notification.go new file mode 100644 index 00000000..8a5aa278 --- /dev/null +++ b/backend/internal/models/notification.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type NotificationType string + +const ( + NotificationTypeInfo NotificationType = "info" + NotificationTypeSuccess NotificationType = "success" + NotificationTypeWarning NotificationType = "warning" + NotificationTypeError NotificationType = "error" +) + +type Notification struct { + ID string `gorm:"primaryKey" json:"id"` + Type NotificationType `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Read bool `json:"read"` + CreatedAt time.Time `json:"created_at"` +} + +func (n *Notification) BeforeCreate(tx *gorm.DB) (err error) { + if n.ID == "" { + n.ID = uuid.New().String() + } + return +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index fd252dd2..49640a95 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -12,7 +12,8 @@ type User struct { ID uint `json:"id" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` Email string `json:"email" gorm:"uniqueIndex"` - PasswordHash string `json:"-"` // Never serialize password hash + APIKey string `json:"api_key" gorm:"uniqueIndex"` // For external API access + PasswordHash string `json:"-"` // Never serialize password hash Name string `json:"name"` Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer" Enabled bool `json:"enabled" gorm:"default:true"` diff --git a/backend/internal/models/user_test.go b/backend/internal/models/user_test.go new file mode 100644 index 00000000..eb3ef30c --- /dev/null +++ b/backend/internal/models/user_test.go @@ -0,0 +1,23 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUser_SetPassword(t *testing.T) { + u := &User{} + err := u.SetPassword("password123") + assert.NoError(t, err) + assert.NotEmpty(t, u.PasswordHash) + assert.NotEqual(t, "password123", u.PasswordHash) +} + +func TestUser_CheckPassword(t *testing.T) { + u := &User{} + _ = u.SetPassword("password123") + + assert.True(t, u.CheckPassword("password123")) + assert.False(t, u.CheckPassword("wrongpassword")) +} diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go new file mode 100644 index 00000000..094a7024 --- /dev/null +++ b/backend/internal/server/server_test.go @@ -0,0 +1,31 @@ +package server + +import ( +"net/http" +"net/http/httptest" +"os" +"path/filepath" +"testing" + +"github.com/gin-gonic/gin" +"github.com/stretchr/testify/assert" +) + +func TestNewRouter(t *testing.T) { +gin.SetMode(gin.TestMode) + +// Create a dummy frontend dir +tempDir := t.TempDir() +err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte(""), 0644) +assert.NoError(t, err) + +router := NewRouter(tempDir) +assert.NotNil(t, router) + +// Test static file serving +req, _ := http.NewRequest("GET", "/", nil) +w := httptest.NewRecorder() +router.ServeHTTP(w, req) +assert.Equal(t, http.StatusOK, w.Code) +assert.Contains(t, w.Body.String(), "") +} diff --git a/backend/internal/services/auth_service.go b/backend/internal/services/auth_service.go index 3d1033b6..e07aeb5e 100644 --- a/backend/internal/services/auth_service.go +++ b/backend/internal/services/auth_service.go @@ -40,6 +40,7 @@ func (s *AuthService) Register(email, password, name string) (*models.User, erro Email: email, Name: name, Role: role, + APIKey: uuid.New().String(), CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go new file mode 100644 index 00000000..a1e65bc2 --- /dev/null +++ b/backend/internal/services/auth_service_test.go @@ -0,0 +1,78 @@ +package services + +import ( + "testing" + "time" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.User{})) + return db +} + +func TestAuthService_Register(t *testing.T) { + db := setupTestDB(t) + cfg := config.Config{JWTSecret: "test-secret"} + service := NewAuthService(db, cfg) + + // Test 1: First user should be admin + admin, err := service.Register("admin@example.com", "password123", "Admin User") + require.NoError(t, err) + assert.Equal(t, "admin", admin.Role) + assert.NotEmpty(t, admin.PasswordHash) + assert.NotEqual(t, "password123", admin.PasswordHash) + + // Test 2: Second user should be regular user + user, err := service.Register("user@example.com", "password123", "Regular User") + require.NoError(t, err) + assert.Equal(t, "user", user.Role) +} + +func TestAuthService_Login(t *testing.T) { + db := setupTestDB(t) + cfg := config.Config{JWTSecret: "test-secret"} + service := NewAuthService(db, cfg) + + // Setup user + _, err := service.Register("test@example.com", "password123", "Test User") + require.NoError(t, err) + + // Test 1: Successful login + token, err := service.Login("test@example.com", "password123") + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Test 2: Invalid password + token, err = service.Login("test@example.com", "wrongpassword") + assert.Error(t, err) + assert.Empty(t, token) + assert.Equal(t, "invalid credentials", err.Error()) + + // Test 3: Account locking + // Fail 4 more times (total 5) + for i := 0; i < 4; i++ { + _, err = service.Login("test@example.com", "wrongpassword") + assert.Error(t, err) + } + + // Check if locked + var user models.User + db.Where("email = ?", "test@example.com").First(&user) + assert.Equal(t, 5, user.FailedLoginAttempts) + assert.NotNil(t, user.LockedUntil) + assert.True(t, user.LockedUntil.After(time.Now())) + + // Try login with correct password while locked + token, err = service.Login("test@example.com", "password123") + assert.Error(t, err) + assert.Equal(t, "account locked", err.Error()) +} diff --git a/backend/internal/services/backup_service_test.go b/backend/internal/services/backup_service_test.go new file mode 100644 index 00000000..03feb091 --- /dev/null +++ b/backend/internal/services/backup_service_test.go @@ -0,0 +1,78 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackupService_CreateAndList(t *testing.T) { + // Setup temp dirs + tmpDir, err := os.MkdirTemp("", "cpm-backup-service-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dataDir := filepath.Join(tmpDir, "data") + err = os.MkdirAll(dataDir, 0755) + require.NoError(t, err) + + // Create dummy DB + dbPath := filepath.Join(dataDir, "cpm.db") + err = os.WriteFile(dbPath, []byte("dummy db"), 0644) + require.NoError(t, err) + + // Create dummy caddy dir + caddyDir := filepath.Join(dataDir, "caddy") + err = os.MkdirAll(caddyDir, 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(caddyDir, "caddy.json"), []byte("{}"), 0644) + require.NoError(t, err) + + cfg := &config.Config{DatabasePath: dbPath} + service := NewBackupService(cfg) + + // Test Create + filename, err := service.CreateBackup() + require.NoError(t, err) + assert.NotEmpty(t, filename) + assert.FileExists(t, filepath.Join(service.BackupDir, filename)) + + // Test List + backups, err := service.ListBackups() + require.NoError(t, err) + assert.Len(t, backups, 1) + assert.Equal(t, filename, backups[0].Filename) + assert.True(t, backups[0].Size > 0) + + // Test Restore (Basic check that it unzips) + // Modify the "current" file to verify restore overwrites/restores it + err = os.WriteFile(dbPath, []byte("modified db"), 0644) + require.NoError(t, err) + + err = service.RestoreBackup(filename) + require.NoError(t, err) + + // Verify content restored + content, err := os.ReadFile(dbPath) + require.NoError(t, err) + assert.Equal(t, "dummy db", string(content)) +} + +func TestBackupService_Cron(t *testing.T) { + // Just verify cron is running/scheduled + tmpDir, err := os.MkdirTemp("", "cpm-backup-cron-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dataDir := filepath.Join(tmpDir, "data") + os.MkdirAll(dataDir, 0755) + cfg := &config.Config{DatabasePath: filepath.Join(dataDir, "cpm.db")} + + service := NewBackupService(cfg) + entries := service.Cron.Entries() + assert.Len(t, entries, 1) +} diff --git a/backend/internal/services/log_service_test.go b/backend/internal/services/log_service_test.go new file mode 100644 index 00000000..86c14e83 --- /dev/null +++ b/backend/internal/services/log_service_test.go @@ -0,0 +1,48 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogService(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cpm-log-service-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dataDir := filepath.Join(tmpDir, "data") + logsDir := filepath.Join(dataDir, "logs") + err = os.MkdirAll(logsDir, 0755) + require.NoError(t, err) + + // Create logs + err = os.WriteFile(filepath.Join(logsDir, "test.log"), []byte("line1\nline2\nline3"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(logsDir, "other.txt"), []byte("ignore me"), 0644) + require.NoError(t, err) + + cfg := &config.Config{DatabasePath: filepath.Join(dataDir, "cpm.db")} + service := NewLogService(cfg) + + // Test List + logs, err := service.ListLogs() + require.NoError(t, err) + assert.Len(t, logs, 1) + assert.Equal(t, "test.log", logs[0].Name) + + // Test Read + lines, err := service.ReadLog("test.log", 2) + require.NoError(t, err) + assert.Len(t, lines, 2) + assert.Equal(t, "line2", lines[0]) + assert.Equal(t, "line3", lines[1]) + + // Test Read non-existent + _, err = service.ReadLog("missing.log", 10) + assert.Error(t, err) +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go new file mode 100644 index 00000000..c551ef2a --- /dev/null +++ b/backend/internal/services/notification_service.go @@ -0,0 +1,43 @@ +package services + +import ( + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "gorm.io/gorm" +) + +type NotificationService struct { + DB *gorm.DB +} + +func NewNotificationService(db *gorm.DB) *NotificationService { + return &NotificationService{DB: db} +} + +func (s *NotificationService) Create(nType models.NotificationType, title, message string) (*models.Notification, error) { + notification := &models.Notification{ + Type: nType, + Title: title, + Message: message, + Read: false, + } + result := s.DB.Create(notification) + return notification, result.Error +} + +func (s *NotificationService) List(unreadOnly bool) ([]models.Notification, error) { + var notifications []models.Notification + query := s.DB.Order("created_at desc") + if unreadOnly { + query = query.Where("read = ?", false) + } + result := query.Find(¬ifications) + return notifications, result.Error +} + +func (s *NotificationService) MarkAsRead(id string) error { + return s.DB.Model(&models.Notification{}).Where("id = ?", id).Update("read", true).Error +} + +func (s *NotificationService) MarkAllAsRead() error { + return s.DB.Model(&models.Notification{}).Where("read = ?", false).Update("read", true).Error +} diff --git a/backend/internal/services/proxyhost_service_test.go b/backend/internal/services/proxyhost_service_test.go new file mode 100644 index 00000000..d63e096c --- /dev/null +++ b/backend/internal/services/proxyhost_service_test.go @@ -0,0 +1,44 @@ +package services + +import ( + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupProxyHostTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{})) + return db +} + +func TestProxyHostService_ValidateUniqueDomain(t *testing.T) { + db := setupProxyHostTestDB(t) + service := NewProxyHostService(db) + + // Create existing host + existing := &models.ProxyHost{ + DomainNames: "example.com", + ForwardHost: "127.0.0.1", + ForwardPort: 8080, + } + require.NoError(t, db.Create(existing).Error) + + // Test 1: Duplicate domain + err := service.ValidateUniqueDomain("example.com", 0) + assert.Error(t, err) + assert.Equal(t, "domain already exists", err.Error()) + + // Test 2: New domain + err = service.ValidateUniqueDomain("new.com", 0) + assert.NoError(t, err) + + // Test 3: Update existing (exclude self) + err = service.ValidateUniqueDomain("example.com", existing.ID) + assert.NoError(t, err) +} diff --git a/backend/internal/services/remoteserver_service_test.go b/backend/internal/services/remoteserver_service_test.go new file mode 100644 index 00000000..8f284497 --- /dev/null +++ b/backend/internal/services/remoteserver_service_test.go @@ -0,0 +1,49 @@ +package services + +import ( + "testing" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupRemoteServerTestDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.RemoteServer{})) + return db +} + +func TestRemoteServerService_ValidateUniqueServer(t *testing.T) { + db := setupRemoteServerTestDB(t) + service := NewRemoteServerService(db) + + // Create existing server + existing := &models.RemoteServer{ + Name: "Existing Server", + Host: "192.168.1.100", + Port: 8080, + } + require.NoError(t, db.Create(existing).Error) + + // Test 1: Duplicate Name + err := service.ValidateUniqueServer("Existing Server", "192.168.1.101", 9090, 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // Test 2: Duplicate Host:Port + err = service.ValidateUniqueServer("New Name", "192.168.1.100", 8080, 0) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // Test 3: New Server + err = service.ValidateUniqueServer("New Server", "192.168.1.101", 8080, 0) + assert.NoError(t, err) + + // Test 4: Update existing (exclude self) + err = service.ValidateUniqueServer("Existing Server", "192.168.1.100", 8080, existing.ID) + assert.NoError(t, err) +} diff --git a/backend/internal/services/update_service.go b/backend/internal/services/update_service.go new file mode 100644 index 00000000..44865680 --- /dev/null +++ b/backend/internal/services/update_service.go @@ -0,0 +1,88 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version" +) + +type UpdateService struct { + currentVersion string + repoOwner string + repoName string + lastCheck time.Time + cachedResult *UpdateInfo +} + +type UpdateInfo struct { + Available bool `json:"available"` + LatestVersion string `json:"latest_version"` + ChangelogURL string `json:"changelog_url"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +func NewUpdateService() *UpdateService { + return &UpdateService{ + currentVersion: version.Version, + repoOwner: "Wikid82", + repoName: "CaddyProxyManagerPlus", + } +} + +func (s *UpdateService) CheckForUpdates() (*UpdateInfo, error) { + // Cache for 1 hour + if s.cachedResult != nil && time.Since(s.lastCheck) < 1*time.Hour { + return s.cachedResult, nil + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", s.repoOwner, s.repoName) + client := &http.Client{Timeout: 5 * time.Second} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "CPMP-Update-Checker") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // If rate limited or not found, just return no update available + return &UpdateInfo{Available: false}, nil + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + + // Simple string comparison for now. + // In production, use a semver library. + // Assuming tags are "v0.1.0" and version is "0.1.0" + latest := release.TagName + if len(latest) > 0 && latest[0] == 'v' { + latest = latest[1:] + } + + info := &UpdateInfo{ + Available: latest != s.currentVersion && latest != "", + LatestVersion: release.TagName, + ChangelogURL: release.HTMLURL, + } + + s.cachedResult = info + s.lastCheck = time.Now() + + return info, nil +} diff --git a/backend/internal/services/uptime_service.go b/backend/internal/services/uptime_service.go new file mode 100644 index 00000000..5e7ce08f --- /dev/null +++ b/backend/internal/services/uptime_service.go @@ -0,0 +1,63 @@ +package services + +import ( + "fmt" + "net" + "time" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "gorm.io/gorm" +) + +type UptimeService struct { + DB *gorm.DB + NotificationService *NotificationService +} + +func NewUptimeService(db *gorm.DB, ns *NotificationService) *UptimeService { + return &UptimeService{ + DB: db, + NotificationService: ns, + } +} + +// CheckHost checks a single host and creates a notification if it's down +func (s *UptimeService) CheckHost(host string, port int) bool { + timeout := 5 * time.Second + target := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", target, timeout) + if err != nil { + return false + } + if conn != nil { + conn.Close() + return true + } + return false +} + +// CheckAllHosts iterates through ProxyHosts and checks their upstream targets +func (s *UptimeService) CheckAllHosts() { + var hosts []models.ProxyHost + if err := s.DB.Find(&hosts).Error; err != nil { + return + } + + for _, host := range hosts { + if !host.Enabled { + continue + } + // Assuming ProxyHost has ForwardHost and ForwardPort + // We need to check if the upstream is reachable + alive := s.CheckHost(host.ForwardHost, host.ForwardPort) + if !alive { + // Check if we already notified recently? For now just notify. + // In a real app, we'd want to avoid spamming. + s.NotificationService.Create( + models.NotificationTypeError, + "Host Unreachable", + fmt.Sprintf("Proxy Host %s (Upstream: %s:%d) is unreachable.", host.DomainNames, host.ForwardHost, host.ForwardPort), + ) + } + } +} diff --git a/backend/internal/version/version_test.go b/backend/internal/version/version_test.go new file mode 100644 index 00000000..70e57a3f --- /dev/null +++ b/backend/internal/version/version_test.go @@ -0,0 +1,27 @@ +package version + +import ( +"testing" + +"github.com/stretchr/testify/assert" +) + +func TestFull(t *testing.T) { +// Default +assert.Contains(t, Full(), Version) + +// With build info +originalBuildTime := BuildTime +originalGitCommit := GitCommit +defer func() { +BuildTime = originalBuildTime +GitCommit = originalGitCommit +}() + +BuildTime = "2023-01-01" +GitCommit = "abcdef" + +full := Full() +assert.Contains(t, full, "2023-01-01") +assert.Contains(t, full, "abcdef") +}