fix: add system permissions handler for diagnostics and repair

- Implemented SystemPermissionsHandler to check and repair file permissions.
- Added endpoints for retrieving and repairing permissions.
- Introduced utility functions for permission checks and error mapping.
- Created tests for the new handler and utility functions.
- Updated routes to include the new permissions endpoints.
- Enhanced configuration to support new logging and plugin directories.
This commit is contained in:
GitHub Actions
2026-02-11 05:33:07 +00:00
parent a1ffe1abba
commit 9ef8a1ce21
42 changed files with 2722 additions and 1135 deletions

View File

@@ -95,6 +95,11 @@ Configure the application via `docker-compose.yml`:
| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). |
| `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). |
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). |
| `CHARON_CADDY_CONFIG_ROOT` | `/config` | Path to Caddy autosave configuration directory. |
| `CHARON_CADDY_LOG_DIR` | `/var/log/caddy` | Directory for Caddy access logs. |
| `CHARON_CROWDSEC_LOG_DIR` | `/var/log/crowdsec` | Directory for CrowdSec logs. |
| `CHARON_PLUGINS_DIR` | `/app/plugins` | Directory for DNS provider plugins. |
| `CHARON_SINGLE_CONTAINER_MODE` | `true` | Enables permission repair endpoints for single-container deployments. |
## NAS Deployment Guides

View File

@@ -37,13 +37,8 @@ Ensure compliance with the following validation requirements:
- **Front Matter**: Include the following fields in the YAML front matter:
- `post_title`: The title of the post.
- `author1`: The primary author of the post.
- `post_slug`: The URL slug for the post.
- `microsoft_alias`: The Microsoft alias of the author.
- `featured_image`: The URL of the featured image.
- `categories`: The categories for the post. These categories must be from the list in /categories.txt.
- `tags`: The tags for the post.
- `ai_note`: Indicate if AI was used in the creation of the post.
- `summary`: A brief summary of the post. Recommend a summary based on the content when possible.
- `post_date`: The publication date of the post.

View File

@@ -870,6 +870,11 @@ CMD ["/app/charon"]
| `CHARON_ENV` | Environment (production/development) | `production` | No |
| `CHARON_ENCRYPTION_KEY` | 32-byte base64 key for credential encryption | Auto-generated | No |
| `CHARON_EMERGENCY_TOKEN` | 64-char hex for break-glass access | None | Optional |
| `CHARON_CADDY_CONFIG_ROOT` | Caddy autosave config root | `/config` | No |
| `CHARON_CADDY_LOG_DIR` | Caddy log directory | `/var/log/caddy` | No |
| `CHARON_CROWDSEC_LOG_DIR` | CrowdSec log directory | `/var/log/crowdsec` | No |
| `CHARON_PLUGINS_DIR` | DNS provider plugin directory | `/app/plugins` | No |
| `CHARON_SINGLE_CONTAINER_MODE` | Enables permission repair endpoints | `true` | No |
| `CROWDSEC_API_KEY` | CrowdSec cloud API key | None | Optional |
| `SMTP_HOST` | SMTP server for notifications | None | Optional |
| `SMTP_PORT` | SMTP port | `587` | Optional |

View File

@@ -1,6 +1,6 @@
module github.com/Wikid82/charon/backend
go 1.26.0
go 1.25.0
require (
github.com/containrrr/shoutrrr v0.8.0

View File

@@ -34,6 +34,7 @@ func TestImportHandler_Commit_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -54,6 +55,7 @@ func TestImportHandler_Commit_InvalidSessionUUID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -76,6 +78,7 @@ func TestImportHandler_Commit_SessionNotFound(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -351,6 +354,7 @@ func TestBackupHandler_List_DBError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
h.List(c)
@@ -368,6 +372,7 @@ func TestImportHandler_UploadMulti_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -390,6 +395,7 @@ func TestImportHandler_UploadMulti_MissingCaddyfile(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -413,6 +419,7 @@ func TestImportHandler_UploadMulti_EmptyContent(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -437,6 +444,7 @@ func TestImportHandler_UploadMulti_PathTraversal(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -525,6 +533,7 @@ func TestImportHandler_Upload_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload", bytes.NewBufferString("not json"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -545,6 +554,7 @@ func TestImportHandler_Upload_EmptyContent(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -583,6 +593,7 @@ func TestBackupHandler_List_ServiceError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("GET", "/backups", http.NoBody)
h.List(c)
@@ -611,6 +622,7 @@ func TestBackupHandler_Delete_PathTraversal(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "filename", Value: "../../../etc/passwd"}}
c.Request = httptest.NewRequest("DELETE", "/backups/../../../etc/passwd", http.NoBody)
@@ -659,6 +671,7 @@ func TestBackupHandler_Delete_InternalError2(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "filename", Value: "test.zip"}}
c.Request = httptest.NewRequest("DELETE", "/backups/test.zip", http.NoBody)
@@ -773,6 +786,7 @@ func TestBackupHandler_Create_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/backups", http.NoBody)
h.Create(c)
@@ -818,6 +832,7 @@ func TestSettingsHandler_UpdateSetting_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings/test", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -893,6 +908,7 @@ func TestImportHandler_UploadMulti_ValidCaddyfile(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -918,6 +934,7 @@ func TestImportHandler_UploadMulti_SubdirFile(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

View File

@@ -12,11 +12,16 @@ import (
)
type BackupHandler struct {
service *services.BackupService
service *services.BackupService
securityService *services.SecurityService
}
func NewBackupHandler(service *services.BackupService) *BackupHandler {
return &BackupHandler{service: service}
return NewBackupHandlerWithDeps(service, nil)
}
func NewBackupHandlerWithDeps(service *services.BackupService, securityService *services.SecurityService) *BackupHandler {
return &BackupHandler{service: service, securityService: securityService}
}
func (h *BackupHandler) List(c *gin.Context) {
@@ -29,9 +34,16 @@ func (h *BackupHandler) List(c *gin.Context) {
}
func (h *BackupHandler) Create(c *gin.Context) {
if !requireAdmin(c) {
return
}
filename, err := h.service.CreateBackup()
if err != nil {
middleware.GetRequestLogger(c).WithField("action", "create_backup").WithError(err).Error("Failed to create backup")
if respondPermissionError(c, h.securityService, "backup_create_failed", err, h.service.BackupDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
return
}
@@ -40,12 +52,19 @@ func (h *BackupHandler) Create(c *gin.Context) {
}
func (h *BackupHandler) Delete(c *gin.Context) {
if !requireAdmin(c) {
return
}
filename := c.Param("filename")
if err := h.service.DeleteBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
if respondPermissionError(c, h.securityService, "backup_delete_failed", err, h.service.BackupDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
return
}
@@ -70,6 +89,10 @@ func (h *BackupHandler) Download(c *gin.Context) {
}
func (h *BackupHandler) Restore(c *gin.Context) {
if !requireAdmin(c) {
return
}
filename := c.Param("filename")
if err := h.service.RestoreBackup(filename); err != nil {
// codeql[go/log-injection] Safe: User input sanitized via util.SanitizeForLog()
@@ -79,6 +102,9 @@ func (h *BackupHandler) Restore(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
if respondPermissionError(c, h.securityService, "backup_restore_failed", err, h.service.BackupDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
return
}

View File

@@ -31,6 +31,8 @@ func TestBackupHandlerSanitizesFilename(t *testing.T) {
// Create a gin test context and use it to call handler directly
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Set("userID", uint(1))
// Ensure request-scoped logger is present and writes to our buffer
c.Set("logger", logger.WithFields(map[string]any{"test": "1"}))

View File

@@ -47,6 +47,11 @@ func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string
h := NewBackupHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
api := r.Group("/api/v1")
// Manually register routes since we don't have a RegisterRoutes method on the handler yet?
// Wait, I didn't check if I added RegisterRoutes to BackupHandler.

View File

@@ -27,6 +27,10 @@ func TestBackupHandlerQuick(t *testing.T) {
h := NewBackupHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
// register routes used
r.GET("/backups", h.List)
r.POST("/backups", h.Create)

View File

@@ -185,6 +185,9 @@ func TestCredentialHandler_Get(t *testing.T) {
created, err := credService.Create(testContext(), provider.ID, createReq)
require.NoError(t, err)
// Give SQLite time to release locks
time.Sleep(10 * time.Millisecond)
url := fmt.Sprintf("/api/v1/dns-providers/%d/credentials/%d", provider.ID, created.ID)
req, _ := http.NewRequest("GET", url, nil)
w := httptest.NewRecorder()

View File

@@ -195,24 +195,6 @@ func (h *EncryptionHandler) Validate(c *gin.Context) {
})
}
// isAdmin checks if the current user has admin privileges.
// This should ideally use the existing auth middleware context.
func isAdmin(c *gin.Context) bool {
// Check if user is authenticated and is admin
// Auth middleware sets "role" context key (not "user_role")
userRole, exists := c.Get("role")
if !exists {
return false
}
role, ok := userRole.(string)
if !ok {
return false
}
return role == "admin"
}
// getActorFromGinContext extracts the user ID from Gin context for audit logging.
func getActorFromGinContext(c *gin.Context) string {
// Auth middleware sets "userID" (not "user_id")

View File

@@ -41,6 +41,14 @@ func setupImportTestDB(t *testing.T) *gorm.DB {
return db
}
func addAdminMiddleware(router *gin.Engine) {
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
}
func TestImportHandler_GetStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
@@ -48,6 +56,8 @@ func TestImportHandler_GetStatus(t *testing.T) {
// Case 1: No active session, no mount
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.DELETE("/import/cancel", handler.Cancel)
session := models.ImportSession{
@@ -72,6 +82,8 @@ func TestImportHandler_Commit(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
session := models.ImportSession{
@@ -119,6 +131,8 @@ func TestImportHandler_Upload(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
@@ -142,6 +156,8 @@ func TestImportHandler_GetPreview_WithContent(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.GET("/import/preview", handler.GetPreview)
// Case: Active session with source file
@@ -176,6 +192,8 @@ func TestImportHandler_Commit_Errors(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
// Case 1: Invalid JSON
@@ -219,6 +237,7 @@ func TestImportHandler_Cancel_Errors(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.DELETE("/import/cancel", handler.Cancel)
// Case 1: Session not found
@@ -270,6 +289,7 @@ func TestImportHandler_Upload_Failure(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
@@ -307,6 +327,7 @@ func TestImportHandler_Upload_Conflict(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
@@ -343,6 +364,7 @@ func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.GET("/import/preview", handler.GetPreview)
// Create backup file
@@ -376,6 +398,7 @@ func TestImportHandler_RegisterRoutes(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
@@ -404,6 +427,7 @@ func TestImportHandler_GetPreview_TransientMount(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
addAdminMiddleware(router)
router.GET("/import/preview", handler.GetPreview)
w := httptest.NewRecorder()
@@ -442,6 +466,7 @@ func TestImportHandler_Commit_TransientUpload(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
@@ -506,6 +531,7 @@ func TestImportHandler_Commit_TransientMount(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
// Commit the mount with a random session ID (transient)
@@ -547,6 +573,7 @@ func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
router.DELETE("/import/cancel", handler.Cancel)
@@ -574,6 +601,7 @@ func TestImportHandler_DetectImports(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/detect-imports", handler.DetectImports)
tests := []struct {
@@ -636,6 +664,7 @@ func TestImportHandler_DetectImports_InvalidJSON(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/detect-imports", handler.DetectImports)
// Invalid JSON
@@ -658,6 +687,7 @@ func TestImportHandler_UploadMulti(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload-multi", handler.UploadMulti)
t.Run("single Caddyfile", func(t *testing.T) {
@@ -765,6 +795,7 @@ func TestImportHandler_Cancel_MissingSessionUUID(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.DELETE("/import/cancel", handler.Cancel)
// Missing session_uuid parameter
@@ -783,6 +814,7 @@ func TestImportHandler_Cancel_InvalidSessionUUID(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.DELETE("/import/cancel", handler.Cancel)
// Test "." which becomes empty after filepath.Base processing
@@ -801,6 +833,7 @@ func TestImportHandler_Commit_InvalidSessionUUID(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
// Test "." which becomes empty after filepath.Base processing
@@ -888,8 +921,10 @@ func TestImportHandler_Commit_UpdateFailure(t *testing.T) {
},
}
handler := handlers.NewImportHandlerWithService(db, mockSvc, "echo", "/tmp", "")
handler := handlers.NewImportHandlerWithService(db, mockSvc, "echo", "/tmp", "", nil)
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
// Request to overwrite existing.com
@@ -953,6 +988,7 @@ func TestImportHandler_Commit_CreateFailure(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
// Don't provide resolution, so it defaults to create (not overwrite)
@@ -994,6 +1030,7 @@ func TestUpload_NormalizationSuccess(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
// Use single-line Caddyfile format (triggers normalization)
@@ -1039,6 +1076,7 @@ func TestUpload_NormalizationFallback(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload", handler.Upload)
// Valid Caddyfile that would parse successfully (even if normalization fails)
@@ -1107,6 +1145,7 @@ func TestCommit_OverwriteAction(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
payload := map[string]any{
@@ -1176,6 +1215,7 @@ func TestCommit_RenameAction(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
payload := map[string]any{
@@ -1241,6 +1281,7 @@ func TestGetPreview_WithConflictDetails(t *testing.T) {
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
addAdminMiddleware(router)
router.GET("/import/preview", handler.GetPreview)
w := httptest.NewRecorder()
@@ -1274,6 +1315,7 @@ func TestSafeJoin_PathTraversalCases(t *testing.T) {
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/upload-multi", handler.UploadMulti)
tests := []struct {
@@ -1360,6 +1402,7 @@ func TestCommit_SkipAction(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
payload := map[string]any{
@@ -1411,6 +1454,7 @@ func TestCommit_CustomNames(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
addAdminMiddleware(router)
router.POST("/import/commit", handler.Commit)
payload := map[string]any{
@@ -1460,6 +1504,7 @@ func TestGetStatus_AlreadyCommittedMount(t *testing.T) {
handler := handlers.NewImportHandler(db, "echo", tmpDir, mountPath)
router := gin.New()
addAdminMiddleware(router)
router.GET("/import/status", handler.GetStatus)
w := httptest.NewRecorder()
@@ -1493,8 +1538,10 @@ func TestImportHandler_Commit_SessionSaveWarning(t *testing.T) {
createFunc: func(h *models.ProxyHost) error { h.ID = 1; return nil },
}
h := handlers.NewImportHandlerWithService(db, mockSvc, "echo", "/tmp", "")
h := handlers.NewImportHandlerWithService(db, mockSvc, "echo", "/tmp", "", nil)
router := gin.New()
addAdminMiddleware(router)
addAdminMiddleware(router)
router.POST("/import/commit", h.Commit)
// Inject a GORM callback to force an error when updating ImportSession (simulates non-fatal save warning)
@@ -1555,6 +1602,8 @@ func TestGetStatus_DatabaseError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Request = httptest.NewRequest("GET", "/api/v1/import/status", nil)
handler.GetStatus(c)
@@ -1587,6 +1636,8 @@ func TestGetPreview_MountAlreadyCommitted(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Request = httptest.NewRequest("GET", "/api/v1/import/preview", nil)
handler.GetPreview(c)
@@ -1611,6 +1662,8 @@ func TestUpload_MkdirAllFailure(t *testing.T) {
reqBody := `{"content": "test.local { reverse_proxy localhost:8080 }", "filename": "test.caddy"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Request = httptest.NewRequest("POST", "/api/v1/import/upload", strings.NewReader(reqBody))
c.Request.Header.Set("Content-Type", "application/json")

View File

@@ -48,28 +48,35 @@ type ImportHandler struct {
importerservice ImporterService
importDir string
mountPath string
securityService *services.SecurityService
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
return NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, nil)
}
func NewImportHandlerWithDeps(db *gorm.DB, caddyBinary, importDir, mountPath string, securityService *services.SecurityService) *ImportHandler {
return &ImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
securityService: securityService,
}
}
// NewImportHandlerWithService creates an import handler with a custom ProxyHostService.
// This is primarily used for testing with mock services.
func NewImportHandlerWithService(db *gorm.DB, proxyHostSvc ProxyHostServiceInterface, caddyBinary, importDir, mountPath string) *ImportHandler {
func NewImportHandlerWithService(db *gorm.DB, proxyHostSvc ProxyHostServiceInterface, caddyBinary, importDir, mountPath string, securityService *services.SecurityService) *ImportHandler {
return &ImportHandler{
db: db,
proxyHostSvc: proxyHostSvc,
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
securityService: securityService,
}
}
@@ -273,6 +280,10 @@ func (h *ImportHandler) GetPreview(c *gin.Context) {
// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
Content string `json:"content" binding:"required"`
Filename string `json:"filename"`
@@ -311,6 +322,9 @@ func (h *ImportHandler) Upload(c *gin.Context) {
}
// #nosec G301 -- Import uploads directory needs group readability for processing
if mkdirErr := os.MkdirAll(uploadsDir, 0o755); mkdirErr != nil {
if respondPermissionError(c, h.securityService, "import_upload_failed", mkdirErr, h.importDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
@@ -322,6 +336,9 @@ func (h *ImportHandler) Upload(c *gin.Context) {
// #nosec G306 -- Caddyfile uploads need group readability for Caddy validation
if writeErr := os.WriteFile(tempPath, []byte(normalizedContent), 0o644); writeErr != nil {
middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(writeErr).Error("Import Upload: failed to write temp file")
if respondPermissionError(c, h.securityService, "import_upload_failed", writeErr, h.importDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}
@@ -435,6 +452,9 @@ func (h *ImportHandler) Upload(c *gin.Context) {
}
if err := h.db.Create(&session).Error; err != nil {
middleware.GetRequestLogger(c).WithError(err).Warn("Import Upload: failed to persist session")
if respondPermissionError(c, h.securityService, "import_upload_failed", err, h.importDir) {
return
}
}
c.JSON(http.StatusOK, gin.H{
@@ -470,6 +490,10 @@ func (h *ImportHandler) DetectImports(c *gin.Context) {
// UploadMulti handles upload of main Caddyfile + multiple site files.
func (h *ImportHandler) UploadMulti(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
Files []struct {
Filename string `json:"filename" binding:"required"`
@@ -504,6 +528,9 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
}
// #nosec G301 -- Session directory with standard permissions for import processing
if mkdirErr := os.MkdirAll(sessionDir, 0o755); mkdirErr != nil {
if respondPermissionError(c, h.securityService, "import_upload_failed", mkdirErr, h.importDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
return
}
@@ -528,6 +555,9 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
if dir := filepath.Dir(targetPath); dir != sessionDir {
// #nosec G301 -- Subdirectory within validated session directory
if mkdirErr := os.MkdirAll(dir, 0o755); mkdirErr != nil {
if respondPermissionError(c, h.securityService, "import_upload_failed", mkdirErr, h.importDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
return
}
@@ -535,6 +565,9 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
// #nosec G306 -- Imported Caddyfile needs to be readable for processing
if writeErr := os.WriteFile(targetPath, []byte(f.Content), 0o644); writeErr != nil {
if respondPermissionError(c, h.securityService, "import_upload_failed", writeErr, h.importDir) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
return
}
@@ -663,6 +696,9 @@ func (h *ImportHandler) UploadMulti(c *gin.Context) {
}
if err := h.db.Create(&session).Error; err != nil {
middleware.GetRequestLogger(c).WithError(err).Warn("Import UploadMulti: failed to persist session")
if respondPermissionError(c, h.securityService, "import_upload_failed", err, h.importDir) {
return
}
}
c.JSON(http.StatusOK, gin.H{
@@ -764,6 +800,10 @@ func safeJoin(baseDir, userPath string) (string, error) {
// Commit finalizes the import with user's conflict resolutions.
func (h *ImportHandler) Commit(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename)
@@ -784,7 +824,7 @@ func (h *ImportHandler) Commit(c *gin.Context) {
return
}
var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil {
if err := h.db.Where("uuid = ? AND status IN ?", sid, []string{"reviewing", "pending"}).First(&session).Error; err == nil {
// DB session found
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
@@ -910,6 +950,9 @@ func (h *ImportHandler) Commit(c *gin.Context) {
}
if err := h.db.Save(&session).Error; err != nil {
middleware.GetRequestLogger(c).WithError(err).Warn("Warning: failed to save import session")
if respondPermissionError(c, h.securityService, "import_commit_failed", err, h.importDir) {
return
}
}
c.JSON(http.StatusOK, gin.H{
@@ -922,6 +965,10 @@ func (h *ImportHandler) Commit(c *gin.Context) {
// Cancel discards a pending import session.
func (h *ImportHandler) Cancel(c *gin.Context) {
if !requireAdmin(c) {
return
}
sessionUUID := c.Query("session_uuid")
if sessionUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
@@ -937,7 +984,11 @@ func (h *ImportHandler) Cancel(c *gin.Context) {
var session models.ImportSession
if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil {
session.Status = "rejected"
h.db.Save(&session)
if saveErr := h.db.Save(&session).Error; saveErr != nil {
if respondPermissionError(c, h.securityService, "import_cancel_failed", saveErr, h.importDir) {
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}
@@ -948,6 +999,9 @@ func (h *ImportHandler) Cancel(c *gin.Context) {
if _, err := os.Stat(uploadsPath); err == nil {
if err := os.Remove(uploadsPath); err != nil {
logger.Log().WithError(err).Warn("Failed to remove upload file")
if respondPermissionError(c, h.securityService, "import_cancel_failed", err, h.importDir) {
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return

View File

@@ -72,6 +72,10 @@ func TestUploadMulti_EmptyList(t *testing.T) {
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
r.POST("/upload-multi", h.UploadMulti)
// Create JSON with empty files list
@@ -116,6 +120,10 @@ func TestUploadMulti_FileServerDetected(t *testing.T) {
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
r.POST("/upload-multi", h.UploadMulti)
req := map[string]interface{}{
@@ -155,6 +163,10 @@ func TestUploadMulti_NoSitesParsed(t *testing.T) {
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
r.POST("/upload-multi", h.UploadMulti)
req := map[string]interface{}{

View File

@@ -28,6 +28,10 @@ func TestImportUploadSanitizesFilename(t *testing.T) {
router := gin.New()
router.Use(middleware.RequestID())
router.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
router.POST("/import/upload", svc.Upload)
buf := &bytes.Buffer{}

View File

@@ -106,6 +106,13 @@ func setupTestHandler(t *testing.T, db *gorm.DB) (*ImportHandler, *mockProxyHost
return handler, mockSvc, mockImport
}
func addAdminMiddleware(router *gin.Engine) {
router.Use(func(c *gin.Context) {
setAdminContext(c)
c.Next()
})
}
// TestUpload_NormalizationSuccess verifies single-line Caddyfile formatting
func TestUpload_NormalizationSuccess(t *testing.T) {
testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) {
@@ -142,6 +149,7 @@ func TestUpload_NormalizationSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -190,6 +198,7 @@ func TestUpload_NormalizationFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -230,6 +239,7 @@ func TestUpload_PathTraversalBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -270,6 +280,7 @@ func TestUploadMulti_ArchiveExtraction(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -315,6 +326,7 @@ func TestUploadMulti_ConflictDetection(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -353,6 +365,7 @@ func TestCommit_TransientToImport(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -397,6 +410,7 @@ func TestCommit_RollbackOnError(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -429,6 +443,7 @@ func TestDetectImports_EmptyCaddyfile(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -573,6 +588,7 @@ func TestImportHandler_Upload_NullByteInjection(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -599,6 +615,7 @@ func TestImportHandler_DetectImports_MalformedFile(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)
@@ -744,6 +761,7 @@ func TestImportHandler_Upload_InvalidSessionPaths(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
addAdminMiddleware(router)
handler.RegisterRoutes(router.Group("/api/v1"))
router.ServeHTTP(w, req)

View File

@@ -80,17 +80,22 @@ func TestLogsLifecycle(t *testing.T) {
var logs []services.LogFile
err := json.Unmarshal(resp.Body.Bytes(), &logs)
require.NoError(t, err)
require.Len(t, logs, 2) // access.log and cpmp.log
require.GreaterOrEqual(t, len(logs), 2)
// Verify content of one log file
found := false
hasAccess := false
hasCharon := false
for _, l := range logs {
if l.Name == "access.log" {
found = true
hasAccess = true
require.Greater(t, l.Size, int64(0))
}
if l.Name == "charon.log" {
hasCharon = true
require.Greater(t, l.Size, int64(0))
}
}
require.True(t, found)
require.True(t, hasAccess)
require.True(t, hasCharon)
// 2. Read log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", http.NoBody)

View File

@@ -23,6 +23,11 @@ func setupNotificationCoverageDB(t *testing.T) *gorm.DB {
return db
}
func setAdminContext(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
}
// Notification Handler Tests
func TestNotificationHandler_List_Error(t *testing.T) {
@@ -36,6 +41,9 @@ func TestNotificationHandler_List_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
setAdminContext(c)
setAdminContext(c)
c.Request = httptest.NewRequest("GET", "/notifications", http.NoBody)
h.List(c)
@@ -56,6 +64,7 @@ func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("GET", "/notifications?unread=true", http.NoBody)
h.List(c)
@@ -74,6 +83,7 @@ func TestNotificationHandler_MarkAsRead_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
h.MarkAsRead(c)
@@ -93,6 +103,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
h.MarkAllAsRead(c)
@@ -113,6 +124,7 @@ func TestNotificationProviderHandler_List_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
h.List(c)
@@ -128,6 +140,7 @@ func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers", bytes.NewBufferString("invalid json"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -155,6 +168,7 @@ func TestNotificationProviderHandler_Create_DBError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -180,6 +194,7 @@ func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -196,6 +211,7 @@ func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
c.Request = httptest.NewRequest("PUT", "/providers/test-id", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -227,6 +243,7 @@ func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: provider.ID}}
c.Request = httptest.NewRequest("PUT", "/providers/"+provider.ID, bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -255,6 +272,7 @@ func TestNotificationProviderHandler_Update_DBError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
c.Request = httptest.NewRequest("PUT", "/providers/test-id", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -275,6 +293,7 @@ func TestNotificationProviderHandler_Delete_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
h.Delete(c)
@@ -291,6 +310,7 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -307,6 +327,7 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
h.Templates(c)
@@ -324,6 +345,7 @@ func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -349,6 +371,7 @@ func TestNotificationProviderHandler_Preview_WithData(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -371,6 +394,7 @@ func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -392,6 +416,7 @@ func TestNotificationTemplateHandler_List_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
h.List(c)
@@ -407,6 +432,7 @@ func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -432,6 +458,7 @@ func TestNotificationTemplateHandler_Create_DBError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -448,6 +475,7 @@ func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
c.Request = httptest.NewRequest("PUT", "/templates/test-id", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -474,6 +502,7 @@ func TestNotificationTemplateHandler_Update_DBError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
c.Request = httptest.NewRequest("PUT", "/templates/test-id", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -494,6 +523,7 @@ func TestNotificationTemplateHandler_Delete_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "test-id"}}
h.Delete(c)
@@ -510,6 +540,7 @@ func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates/preview", bytes.NewBufferString("invalid"))
c.Request.Header.Set("Content-Type", "application/json")
@@ -531,6 +562,7 @@ func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -563,6 +595,7 @@ func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -584,6 +617,7 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/templates/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

View File

@@ -13,11 +13,17 @@ import (
)
type NotificationProviderHandler struct {
service *services.NotificationService
service *services.NotificationService
securityService *services.SecurityService
dataRoot string
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
return &NotificationProviderHandler{service: service}
return NewNotificationProviderHandlerWithDeps(service, nil, "")
}
func NewNotificationProviderHandlerWithDeps(service *services.NotificationService, securityService *services.SecurityService, dataRoot string) *NotificationProviderHandler {
return &NotificationProviderHandler{service: service, securityService: securityService, dataRoot: dataRoot}
}
func (h *NotificationProviderHandler) List(c *gin.Context) {
@@ -30,6 +36,10 @@ func (h *NotificationProviderHandler) List(c *gin.Context) {
}
func (h *NotificationProviderHandler) Create(c *gin.Context) {
if !requireAdmin(c) {
return
}
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -42,6 +52,9 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return
}
@@ -49,6 +62,10 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
}
func (h *NotificationProviderHandler) Update(c *gin.Context) {
if !requireAdmin(c) {
return
}
id := c.Param("id")
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
@@ -62,6 +79,9 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return
}
@@ -69,8 +89,15 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
}
func (h *NotificationProviderHandler) Delete(c *gin.Context) {
if !requireAdmin(c) {
return
}
id := c.Param("id")
if err := h.service.DeleteProvider(id); err != nil {
if respondPermissionError(c, h.securityService, "notification_provider_delete_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}

View File

@@ -26,6 +26,11 @@ func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
handler := handlers.NewNotificationProviderHandler(service)
r := gin.Default()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
api := r.Group("/api/v1")
providers := api.Group("/notifications/providers")
providers.GET("", handler.List)

View File

@@ -9,11 +9,17 @@ import (
)
type NotificationTemplateHandler struct {
service *services.NotificationService
service *services.NotificationService
securityService *services.SecurityService
dataRoot string
}
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
return &NotificationTemplateHandler{service: s}
return NewNotificationTemplateHandlerWithDeps(s, nil, "")
}
func NewNotificationTemplateHandlerWithDeps(s *services.NotificationService, securityService *services.SecurityService, dataRoot string) *NotificationTemplateHandler {
return &NotificationTemplateHandler{service: s, securityService: securityService, dataRoot: dataRoot}
}
func (h *NotificationTemplateHandler) List(c *gin.Context) {
@@ -26,12 +32,19 @@ func (h *NotificationTemplateHandler) List(c *gin.Context) {
}
func (h *NotificationTemplateHandler) Create(c *gin.Context) {
if !requireAdmin(c) {
return
}
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateTemplate(&t); err != nil {
if respondPermissionError(c, h.securityService, "notification_template_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
return
}
@@ -39,6 +52,10 @@ func (h *NotificationTemplateHandler) Create(c *gin.Context) {
}
func (h *NotificationTemplateHandler) Update(c *gin.Context) {
if !requireAdmin(c) {
return
}
id := c.Param("id")
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
@@ -47,6 +64,9 @@ func (h *NotificationTemplateHandler) Update(c *gin.Context) {
}
t.ID = id
if err := h.service.UpdateTemplate(&t); err != nil {
if respondPermissionError(c, h.securityService, "notification_template_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
return
}
@@ -54,8 +74,15 @@ func (h *NotificationTemplateHandler) Update(c *gin.Context) {
}
func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
if !requireAdmin(c) {
return
}
id := c.Param("id")
if err := h.service.DeleteTemplate(id); err != nil {
if respondPermissionError(c, h.securityService, "notification_template_delete_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
return
}

View File

@@ -26,6 +26,11 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) {
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
api := r.Group("/api/v1")
api.GET("/notifications/templates", h.List)
api.POST("/notifications/templates", h.Create)
@@ -89,6 +94,11 @@ func TestNotificationTemplateHandler_Create_InvalidJSON(t *testing.T) {
svc := services.NewNotificationService(db)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
r.POST("/api/templates", h.Create)
req := httptest.NewRequest(http.MethodPost, "/api/templates", strings.NewReader(`{invalid}`))
@@ -105,6 +115,11 @@ func TestNotificationTemplateHandler_Update_InvalidJSON(t *testing.T) {
svc := services.NewNotificationService(db)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
r.PUT("/api/templates/:id", h.Update)
req := httptest.NewRequest(http.MethodPut, "/api/templates/test-id", strings.NewReader(`{invalid}`))
@@ -121,6 +136,11 @@ func TestNotificationTemplateHandler_Preview_InvalidJSON(t *testing.T) {
svc := services.NewNotificationService(db)
h := NewNotificationTemplateHandler(svc)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
r.POST("/api/templates/preview", h.Preview)
req := httptest.NewRequest(http.MethodPost, "/api/templates/preview", strings.NewReader(`{invalid}`))

View File

@@ -0,0 +1,110 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
func requireAdmin(c *gin.Context) bool {
if isAdmin(c) {
return true
}
c.JSON(http.StatusForbidden, gin.H{
"error": "admin privileges required",
"error_code": "permissions_admin_only",
})
return false
}
func isAdmin(c *gin.Context) bool {
role, _ := c.Get("role")
roleStr, _ := role.(string)
return roleStr == "admin"
}
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {
code, ok := util.MapSaveErrorCode(err)
if !ok {
return false
}
admin := isAdmin(c)
response := gin.H{
"error": permissionErrorMessage(code),
"error_code": code,
}
if admin {
if path != "" {
response["path"] = path
}
response["help"] = buildPermissionHelp(path)
} else {
response["help"] = "Check volume permissions or contact an administrator."
}
logPermissionAudit(securityService, c, action, code, path, admin)
c.JSON(http.StatusInternalServerError, response)
return true
}
func permissionErrorMessage(code string) string {
switch code {
case "permissions_db_readonly":
return "database is read-only"
case "permissions_db_locked":
return "database is locked"
case "permissions_readonly":
return "filesystem is read-only"
case "permissions_write_denied":
return "permission denied"
default:
return "permission error"
}
}
func buildPermissionHelp(path string) string {
uid := os.Geteuid()
gid := os.Getegid()
if path == "" {
return fmt.Sprintf("chown -R %d:%d <path-to-volume>", uid, gid)
}
return fmt.Sprintf("chown -R %d:%d %s", uid, gid, path)
}
func logPermissionAudit(securityService *services.SecurityService, c *gin.Context, action, code, path string, admin bool) {
if securityService == nil {
return
}
details := map[string]any{
"error_code": code,
"admin": admin,
}
if admin && path != "" {
details["path"] = path
}
detailsJSON, _ := json.Marshal(details)
actor := "unknown"
if userID, ok := c.Get("userID"); ok {
actor = fmt.Sprintf("%v", userID)
}
_ = securityService.LogAudit(&models.SecurityAudit{
Actor: actor,
Action: action,
EventCategory: "permissions",
Details: string(detailsJSON),
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
})
}

View File

@@ -3,11 +3,14 @@ package handlers
import (
"fmt"
"net/http"
"net/mail"
"strings"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/security"
"github.com/Wikid82/charon/backend/internal/services"
)
// SecurityNotificationServiceInterface defines the interface for security notification service.
@@ -18,12 +21,18 @@ type SecurityNotificationServiceInterface interface {
// SecurityNotificationHandler handles notification settings endpoints.
type SecurityNotificationHandler struct {
service SecurityNotificationServiceInterface
service SecurityNotificationServiceInterface
securityService *services.SecurityService
dataRoot string
}
// NewSecurityNotificationHandler creates a new handler instance.
func NewSecurityNotificationHandler(service SecurityNotificationServiceInterface) *SecurityNotificationHandler {
return &SecurityNotificationHandler{service: service}
return NewSecurityNotificationHandlerWithDeps(service, nil, "")
}
func NewSecurityNotificationHandlerWithDeps(service SecurityNotificationServiceInterface, securityService *services.SecurityService, dataRoot string) *SecurityNotificationHandler {
return &SecurityNotificationHandler{service: service, securityService: securityService, dataRoot: dataRoot}
}
// GetSettings retrieves the current notification settings.
@@ -38,6 +47,10 @@ func (h *SecurityNotificationHandler) GetSettings(c *gin.Context) {
// UpdateSettings updates the notification settings.
func (h *SecurityNotificationHandler) UpdateSettings(c *gin.Context) {
if !requireAdmin(c) {
return
}
var config models.NotificationConfig
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
@@ -66,10 +79,48 @@ func (h *SecurityNotificationHandler) UpdateSettings(c *gin.Context) {
}
}
if normalized, err := normalizeEmailRecipients(config.EmailRecipients); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
} else {
config.EmailRecipients = normalized
}
if err := h.service.UpdateSettings(&config); err != nil {
if respondPermissionError(c, h.securityService, "security_notifications_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Settings updated successfully"})
}
func normalizeEmailRecipients(input string) (string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", nil
}
parts := strings.Split(trimmed, ",")
valid := make([]string, 0, len(parts))
invalid := make([]string, 0)
for _, part := range parts {
candidate := strings.TrimSpace(part)
if candidate == "" {
continue
}
if _, err := mail.ParseAddress(candidate); err != nil {
invalid = append(invalid, candidate)
continue
}
valid = append(valid, candidate)
}
if len(invalid) > 0 {
return "", fmt.Errorf("invalid email recipients: %s", strings.Join(invalid, ", "))
}
return strings.Join(valid, ", "), nil
}

View File

@@ -137,6 +137,7 @@ func TestSecurityNotificationHandler_UpdateSettings_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(malformedJSON))
c.Request.Header.Set("Content-Type", "application/json")
@@ -182,6 +183,7 @@ func TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel(t *testin
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -233,6 +235,7 @@ func TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF(t *te
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -284,6 +287,7 @@ func TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook(t *testing.
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -320,6 +324,7 @@ func TestSecurityNotificationHandler_UpdateSettings_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -363,6 +368,7 @@ func TestSecurityNotificationHandler_UpdateSettings_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -411,6 +417,7 @@ func TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL(t *testing.T
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")

View File

@@ -33,6 +33,8 @@ type SettingsHandler struct {
MailService *services.MailService
CaddyManager CaddyConfigManager // For triggering config reload on security settings change
Cerberus CacheInvalidator // For invalidating cache on security settings change
SecuritySvc *services.SecurityService
DataRoot string
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
@@ -43,12 +45,14 @@ func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
}
// NewSettingsHandlerWithDeps creates a SettingsHandler with all dependencies for config reload
func NewSettingsHandlerWithDeps(db *gorm.DB, caddyMgr CaddyConfigManager, cerberus CacheInvalidator) *SettingsHandler {
func NewSettingsHandlerWithDeps(db *gorm.DB, caddyMgr CaddyConfigManager, cerberus CacheInvalidator, securitySvc *services.SecurityService, dataRoot string) *SettingsHandler {
return &SettingsHandler{
DB: db,
MailService: services.NewMailService(db),
CaddyManager: caddyMgr,
Cerberus: cerberus,
SecuritySvc: securitySvc,
DataRoot: dataRoot,
}
}
@@ -78,6 +82,10 @@ type UpdateSettingRequest struct {
// UpdateSetting updates or creates a setting.
func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req UpdateSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -105,6 +113,9 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
// Upsert
if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
return
}
@@ -117,6 +128,9 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
Type: "bool",
}
if err := h.DB.Where(models.Setting{Key: cerberusSetting.Key}).Assign(cerberusSetting).FirstOrCreate(&cerberusSetting).Error; err != nil {
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
@@ -127,10 +141,16 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
Type: "bool",
}
if err := h.DB.Where(models.Setting{Key: legacyCerberus.Key}).Assign(legacyCerberus).FirstOrCreate(&legacyCerberus).Error; err != nil {
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable Cerberus"})
return
}
if err := h.ensureSecurityConfigEnabled(); err != nil {
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
return
}
@@ -142,6 +162,9 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
return
}
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security config"})
return
}
@@ -176,9 +199,7 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
// PATCH /api/v1/config
// Requires admin authentication
func (h *SettingsHandler) PatchConfig(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -202,46 +223,49 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
updates["feature.cerberus.enabled"] = "true"
}
// Validate and apply each update
for key, value := range updates {
// Special validation for admin_whitelist (CIDR format)
if key == "security.admin_whitelist" {
if err := validateAdminWhitelist(value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)})
return
if err := h.DB.Transaction(func(tx *gorm.DB) error {
for key, value := range updates {
if key == "security.admin_whitelist" {
if err := validateAdminWhitelist(value); err != nil {
return fmt.Errorf("invalid admin_whitelist: %w", err)
}
}
setting := models.Setting{
Key: key,
Value: value,
Category: strings.Split(key, ".")[0],
Type: "string",
}
if err := tx.Where(models.Setting{Key: key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
return fmt.Errorf("save setting %s: %w", key, err)
}
}
// Upsert setting
setting := models.Setting{
Key: key,
Value: value,
Category: strings.Split(key, ".")[0],
Type: "string",
}
if err := h.DB.Where(models.Setting{Key: key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save setting %s", key)})
return
}
}
if hasAdminWhitelist {
if err := h.syncAdminWhitelist(adminWhitelist); err != nil {
if errors.Is(err, services.ErrInvalidAdminCIDR) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
return
if hasAdminWhitelist {
if err := h.syncAdminWhitelistWithDB(tx, adminWhitelist); err != nil {
return err
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update security config"})
return
}
}
if aclEnabled {
if err := h.ensureSecurityConfigEnabled(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable security config"})
if aclEnabled {
if err := h.ensureSecurityConfigEnabledWithDB(tx); err != nil {
return err
}
}
return nil
}); err != nil {
if errors.Is(err, services.ErrInvalidAdminCIDR) || strings.Contains(err.Error(), "invalid admin_whitelist") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
return
}
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save settings"})
return
}
// Trigger cache invalidation and Caddy reload for security settings
@@ -277,6 +301,9 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
// Return current config state
var settings []models.Setting
if err := h.DB.Find(&settings).Error; err != nil {
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch updated config"})
return
}
@@ -291,19 +318,23 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
}
func (h *SettingsHandler) ensureSecurityConfigEnabled() error {
return h.ensureSecurityConfigEnabledWithDB(h.DB)
}
func (h *SettingsHandler) ensureSecurityConfigEnabledWithDB(db *gorm.DB) error {
var cfg models.SecurityConfig
err := h.DB.Where("name = ?", "default").First(&cfg).Error
err := db.Where("name = ?", "default").First(&cfg).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
cfg = models.SecurityConfig{Name: "default", Enabled: true}
return h.DB.Create(&cfg).Error
return db.Create(&cfg).Error
}
return err
}
if cfg.Enabled {
return nil
}
return h.DB.Model(&cfg).Update("enabled", true).Error
return db.Model(&cfg).Update("enabled", true).Error
}
// flattenConfig converts nested map to flat key-value pairs with dot notation
@@ -348,7 +379,11 @@ func validateAdminWhitelist(whitelist string) error {
}
func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error {
securitySvc := services.NewSecurityService(h.DB)
return h.syncAdminWhitelistWithDB(h.DB, whitelist)
}
func (h *SettingsHandler) syncAdminWhitelistWithDB(db *gorm.DB, whitelist string) error {
securitySvc := services.NewSecurityService(db)
cfg, err := securitySvc.Get()
if err != nil {
if err != services.ErrSecurityConfigNotFound {
@@ -408,9 +443,7 @@ func MaskPasswordForTest(password string) string {
// UpdateSMTPConfig updates the SMTP configuration.
func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -436,6 +469,9 @@ func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) {
}
if err := h.MailService.SaveSMTPConfig(config); err != nil {
if respondPermissionError(c, h.SecuritySvc, "smtp_save_failed", err, h.DataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save SMTP configuration: " + err.Error()})
return
}
@@ -445,9 +481,7 @@ func (h *SettingsHandler) UpdateSMTPConfig(c *gin.Context) {
// TestSMTPConfig tests the SMTP connection.
func (h *SettingsHandler) TestSMTPConfig(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -467,9 +501,7 @@ func (h *SettingsHandler) TestSMTPConfig(c *gin.Context) {
// SendTestEmail sends a test email to verify the SMTP configuration.
func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -515,9 +547,7 @@ func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
// ValidatePublicURL validates a URL is properly formatted for use as the application URL.
func (h *SettingsHandler) ValidatePublicURL(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -559,10 +589,7 @@ func (h *SettingsHandler) ValidatePublicURL(c *gin.Context) {
// 3. Runtime protection: ssrfSafeDialer validates IPs again at connection time
// This multi-layer approach satisfies both static analysis (CodeQL) and runtime security.
func (h *SettingsHandler) TestPublicURL(c *gin.Context) {
// Admin-only access check
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}

View File

@@ -127,6 +127,16 @@ func setupSettingsTestDB(t *testing.T) *gorm.DB {
return db
}
func newAdminRouter() *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Set("userID", uint(1))
c.Next()
})
return router
}
func TestSettingsHandler_GetSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
@@ -135,7 +145,7 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
db.Create(&models.Setting{Key: "test_key", Value: "test_value", Category: "general", Type: "string"})
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
@@ -159,7 +169,7 @@ func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) {
_ = sqlDB.Close()
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
@@ -178,7 +188,7 @@ func TestSettingsHandler_UpdateSettings(t *testing.T) {
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Test Create
@@ -221,7 +231,7 @@ func TestSettingsHandler_UpdateSetting_SyncsAdminWhitelist(t *testing.T) {
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
@@ -248,7 +258,7 @@ func TestSettingsHandler_UpdateSetting_EnablesCerberusWhenACLEnabled(t *testing.
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
@@ -290,7 +300,7 @@ func TestSettingsHandler_PatchConfig_SyncsAdminWhitelist(t *testing.T) {
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -322,7 +332,7 @@ func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -361,7 +371,7 @@ func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) {
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Close the database to force an error
@@ -391,7 +401,7 @@ func TestSettingsHandler_Errors(t *testing.T) {
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
// Invalid JSON
@@ -438,7 +448,7 @@ func TestSettingsHandler_GetSMTPConfig(t *testing.T) {
db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "starttls", Category: "smtp", Type: "string"})
router := gin.New()
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
@@ -459,7 +469,7 @@ func TestSettingsHandler_GetSMTPConfig_Empty(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
@@ -479,7 +489,7 @@ func TestSettingsHandler_GetSMTPConfig_DatabaseError(t *testing.T) {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
router := gin.New()
router := newAdminRouter()
router.GET("/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
@@ -493,7 +503,7 @@ func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
@@ -519,7 +529,7 @@ func TestSettingsHandler_UpdateSMTPConfig_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -538,7 +548,7 @@ func TestSettingsHandler_UpdateSMTPConfig_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -573,7 +583,7 @@ func TestSettingsHandler_UpdateSMTPConfig_KeepExistingPassword(t *testing.T) {
db.Create(&models.Setting{Key: "smtp_from_address", Value: "old@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -606,7 +616,7 @@ func TestSettingsHandler_TestSMTPConfig_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
@@ -624,7 +634,7 @@ func TestSettingsHandler_TestSMTPConfig_NotConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -652,7 +662,7 @@ func TestSettingsHandler_TestSMTPConfig_Success(t *testing.T) {
db.Create(&models.Setting{Key: "smtp_port", Value: fmt.Sprintf("%d", port), Category: "smtp", Type: "number"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -674,7 +684,7 @@ func TestSettingsHandler_SendTestEmail_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
@@ -695,7 +705,7 @@ func TestSettingsHandler_SendTestEmail_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -714,7 +724,7 @@ func TestSettingsHandler_SendTestEmail_NotConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -746,7 +756,7 @@ func TestSettingsHandler_SendTestEmail_Success(t *testing.T) {
db.Create(&models.Setting{Key: "smtp_from_address", Value: "noreply@example.com", Category: "smtp", Type: "string"})
db.Create(&models.Setting{Key: "smtp_encryption", Value: "none", Category: "smtp", Type: "string"})
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -780,7 +790,7 @@ func TestSettingsHandler_ValidatePublicURL_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
@@ -801,7 +811,7 @@ func TestSettingsHandler_ValidatePublicURL_InvalidFormat(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -838,7 +848,7 @@ func TestSettingsHandler_ValidatePublicURL_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -878,7 +888,7 @@ func TestSettingsHandler_TestPublicURL_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
@@ -899,7 +909,7 @@ func TestSettingsHandler_TestPublicURL_NoRole(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
// No role set in context
router.POST("/settings/test-url", handler.TestPublicURL)
@@ -917,7 +927,7 @@ func TestSettingsHandler_TestPublicURL_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -936,7 +946,7 @@ func TestSettingsHandler_TestPublicURL_InvalidURL(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -961,7 +971,7 @@ func TestSettingsHandler_TestPublicURL_PrivateIPBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1017,7 +1027,7 @@ func TestSettingsHandler_TestPublicURL_Success(t *testing.T) {
// Alternative: Refactor handler to accept injectable URL validator (future improvement).
publicTestURL := "https://example.com"
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1045,7 +1055,7 @@ func TestSettingsHandler_TestPublicURL_DNSFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1074,7 +1084,7 @@ func TestSettingsHandler_TestPublicURL_ConnectivityError(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1165,7 +1175,7 @@ func TestSettingsHandler_TestPublicURL_SSRFProtection(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1200,7 +1210,7 @@ func TestSettingsHandler_TestPublicURL_EmbeddedCredentials(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1228,7 +1238,7 @@ func TestSettingsHandler_TestPublicURL_EmptyURL(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1260,7 +1270,7 @@ func TestSettingsHandler_TestPublicURL_InvalidScheme(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1300,7 +1310,7 @@ func TestSettingsHandler_ValidatePublicURL_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1319,7 +1329,7 @@ func TestSettingsHandler_ValidatePublicURL_URLWithWarning(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1350,7 +1360,7 @@ func TestSettingsHandler_UpdateSMTPConfig_DatabaseError(t *testing.T) {
sqlDB, _ := db.DB()
_ = sqlDB.Close()
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
@@ -1379,7 +1389,7 @@ func TestSettingsHandler_TestPublicURL_IPv6LocalhostBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router := newAdminRouter()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()

View File

@@ -0,0 +1,437 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
type PermissionChecker interface {
Check(path, required string) util.PermissionCheck
}
type OSChecker struct{}
func (OSChecker) Check(path, required string) util.PermissionCheck {
return util.CheckPathPermissions(path, required)
}
type SystemPermissionsHandler struct {
cfg config.Config
checker PermissionChecker
securityService *services.SecurityService
}
type permissionsPathSpec struct {
Path string
Required string
}
type permissionsRepairRequest struct {
Paths []string `json:"paths" binding:"required,min=1"`
GroupMode bool `json:"group_mode"`
}
type permissionsRepairResult struct {
Path string `json:"path"`
Status string `json:"status"`
OwnerUID int `json:"owner_uid,omitempty"`
OwnerGID int `json:"owner_gid,omitempty"`
ModeBefore string `json:"mode_before,omitempty"`
ModeAfter string `json:"mode_after,omitempty"`
Message string `json:"message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
func NewSystemPermissionsHandler(cfg config.Config, securityService *services.SecurityService, checker PermissionChecker) *SystemPermissionsHandler {
if checker == nil {
checker = OSChecker{}
}
return &SystemPermissionsHandler{
cfg: cfg,
checker: checker,
securityService: securityService,
}
}
func (h *SystemPermissionsHandler) GetPermissions(c *gin.Context) {
if !requireAdmin(c) {
h.logAudit(c, "permissions_diagnostics", "blocked", "permissions_admin_only", 0)
return
}
paths := h.defaultPaths()
results := make([]util.PermissionCheck, 0, len(paths))
for _, spec := range paths {
results = append(results, h.checker.Check(spec.Path, spec.Required))
}
h.logAudit(c, "permissions_diagnostics", "ok", "", len(results))
c.JSON(http.StatusOK, gin.H{"paths": results})
}
func (h *SystemPermissionsHandler) RepairPermissions(c *gin.Context) {
if !requireAdmin(c) {
h.logAudit(c, "permissions_repair", "blocked", "permissions_admin_only", 0)
return
}
if !h.cfg.SingleContainer {
h.logAudit(c, "permissions_repair", "blocked", "permissions_repair_disabled", 0)
c.JSON(http.StatusForbidden, gin.H{
"error": "repair disabled",
"error_code": "permissions_repair_disabled",
})
return
}
if os.Geteuid() != 0 {
h.logAudit(c, "permissions_repair", "blocked", "permissions_non_root", 0)
c.JSON(http.StatusForbidden, gin.H{
"error": "root privileges required",
"error_code": "permissions_non_root",
})
return
}
var req permissionsRepairRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
results := make([]permissionsRepairResult, 0, len(req.Paths))
allowlist := h.allowlistRoots()
for _, rawPath := range req.Paths {
result := h.repairPath(rawPath, req.GroupMode, allowlist)
results = append(results, result)
}
h.logAudit(c, "permissions_repair", "ok", "", len(results))
c.JSON(http.StatusOK, gin.H{"paths": results})
}
func (h *SystemPermissionsHandler) repairPath(rawPath string, groupMode bool, allowlist []string) permissionsRepairResult {
cleanPath, invalidCode := normalizePath(rawPath)
if invalidCode != "" {
return permissionsRepairResult{
Path: rawPath,
Status: "error",
ErrorCode: invalidCode,
Message: "invalid path",
}
}
info, err := os.Lstat(cleanPath)
if err != nil {
if os.IsNotExist(err) {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_missing_path",
Message: "path does not exist",
}
}
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_repair_failed",
Message: err.Error(),
}
}
if info.Mode()&os.ModeSymlink != 0 {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_symlink_rejected",
Message: "symlink not allowed",
}
}
hasSymlinkComponent, symlinkErr := pathHasSymlink(cleanPath)
if symlinkErr != nil {
if os.IsNotExist(symlinkErr) {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_missing_path",
Message: "path does not exist",
}
}
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_repair_failed",
Message: symlinkErr.Error(),
}
}
if hasSymlinkComponent {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_symlink_rejected",
Message: "symlink not allowed",
}
}
resolved, err := filepath.EvalSymlinks(cleanPath)
if err != nil {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_repair_failed",
Message: err.Error(),
}
}
if !isWithinAllowlist(resolved, allowlist) {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_outside_allowlist",
Message: "path outside allowlist",
}
}
if !info.IsDir() && !info.Mode().IsRegular() {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_unsupported_type",
Message: "unsupported path type",
}
}
uid := os.Geteuid()
gid := os.Getegid()
modeBefore := fmt.Sprintf("%04o", info.Mode().Perm())
modeAfter := targetMode(info.IsDir(), groupMode)
alreadyOwned := isOwnedBy(info, uid, gid)
alreadyMode := modeBefore == modeAfter
if alreadyOwned && alreadyMode {
return permissionsRepairResult{
Path: cleanPath,
Status: "skipped",
OwnerUID: uid,
OwnerGID: gid,
ModeBefore: modeBefore,
ModeAfter: modeAfter,
Message: "ownership and mode already correct",
ErrorCode: "permissions_repair_skipped",
}
}
if err := os.Chown(cleanPath, uid, gid); err != nil {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: mapRepairErrorCode(err),
Message: err.Error(),
}
}
parsedMode, parseErr := parseMode(modeAfter)
if parseErr != nil {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: "permissions_repair_failed",
Message: parseErr.Error(),
}
}
if err := os.Chmod(cleanPath, parsedMode); err != nil {
return permissionsRepairResult{
Path: cleanPath,
Status: "error",
ErrorCode: mapRepairErrorCode(err),
Message: err.Error(),
}
}
return permissionsRepairResult{
Path: cleanPath,
Status: "repaired",
OwnerUID: uid,
OwnerGID: gid,
ModeBefore: modeBefore,
ModeAfter: modeAfter,
Message: "ownership and mode updated",
}
}
func (h *SystemPermissionsHandler) defaultPaths() []permissionsPathSpec {
dataRoot := filepath.Dir(h.cfg.DatabasePath)
return []permissionsPathSpec{
{Path: dataRoot, Required: "rwx"},
{Path: h.cfg.DatabasePath, Required: "rw"},
{Path: filepath.Join(dataRoot, "backups"), Required: "rwx"},
{Path: filepath.Join(dataRoot, "imports"), Required: "rwx"},
{Path: filepath.Join(dataRoot, "caddy"), Required: "rwx"},
{Path: filepath.Join(dataRoot, "crowdsec"), Required: "rwx"},
{Path: filepath.Join(dataRoot, "geoip"), Required: "rwx"},
{Path: h.cfg.ConfigRoot, Required: "rwx"},
{Path: h.cfg.CaddyLogDir, Required: "rwx"},
{Path: h.cfg.CrowdSecLogDir, Required: "rwx"},
{Path: h.cfg.PluginsDir, Required: "r-x"},
}
}
func (h *SystemPermissionsHandler) allowlistRoots() []string {
dataRoot := filepath.Dir(h.cfg.DatabasePath)
return []string{
dataRoot,
h.cfg.ConfigRoot,
h.cfg.CaddyLogDir,
h.cfg.CrowdSecLogDir,
}
}
func (h *SystemPermissionsHandler) logAudit(c *gin.Context, action, result, code string, pathCount int) {
if h.securityService == nil {
return
}
payload := map[string]any{
"result": result,
"error_code": code,
"path_count": pathCount,
"admin": isAdmin(c),
}
payloadJSON, _ := json.Marshal(payload)
actor := "unknown"
if userID, ok := c.Get("userID"); ok {
actor = fmt.Sprintf("%v", userID)
}
_ = h.securityService.LogAudit(&models.SecurityAudit{
Actor: actor,
Action: action,
EventCategory: "permissions",
Details: string(payloadJSON),
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
})
}
func normalizePath(rawPath string) (string, string) {
if rawPath == "" {
return "", "permissions_invalid_path"
}
if !filepath.IsAbs(rawPath) {
return "", "permissions_invalid_path"
}
clean := filepath.Clean(rawPath)
if clean == "." || clean == ".." {
return "", "permissions_invalid_path"
}
if containsParentReference(clean) {
return "", "permissions_invalid_path"
}
return clean, ""
}
func containsParentReference(clean string) bool {
if clean == ".." {
return true
}
if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return true
}
if strings.Contains(clean, string(os.PathSeparator)+".."+string(os.PathSeparator)) {
return true
}
return strings.HasSuffix(clean, string(os.PathSeparator)+"..")
}
func pathHasSymlink(path string) (bool, error) {
clean := filepath.Clean(path)
parts := strings.Split(clean, string(os.PathSeparator))
current := string(os.PathSeparator)
for _, part := range parts {
if part == "" {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
return false, err
}
if info.Mode()&os.ModeSymlink != 0 {
return true, nil
}
}
return false, nil
}
func isWithinAllowlist(path string, allowlist []string) bool {
for _, root := range allowlist {
rel, err := filepath.Rel(root, path)
if err != nil {
continue
}
if rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") {
return true
}
}
return false
}
func targetMode(isDir, groupMode bool) string {
if isDir {
if groupMode {
return "0770"
}
return "0700"
}
if groupMode {
return "0660"
}
return "0600"
}
func parseMode(mode string) (os.FileMode, error) {
if mode == "" {
return 0, fmt.Errorf("mode required")
}
var parsed uint32
if _, err := fmt.Sscanf(mode, "%o", &parsed); err != nil {
return 0, fmt.Errorf("parse mode: %w", err)
}
return os.FileMode(parsed), nil
}
func isOwnedBy(info os.FileInfo, uid, gid int) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return int(stat.Uid) == uid && int(stat.Gid) == gid
}
func mapRepairErrorCode(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, syscall.EROFS):
return "permissions_readonly"
case errors.Is(err, syscall.EACCES) || os.IsPermission(err):
return "permissions_write_denied"
default:
return "permissions_repair_failed"
}
}

View File

@@ -0,0 +1,107 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/util"
)
type stubPermissionChecker struct{}
func (stubPermissionChecker) Check(path, required string) util.PermissionCheck {
return util.PermissionCheck{
Path: path,
Required: required,
Exists: true,
Writable: true,
OwnerUID: 1000,
OwnerGID: 1000,
Mode: "0755",
}
}
func TestSystemPermissionsHandler_GetPermissions_Admin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{
DatabasePath: "/app/data/charon.db",
ConfigRoot: "/config",
CaddyLogDir: "/var/log/caddy",
CrowdSecLogDir: "/var/log/crowdsec",
PluginsDir: "/app/plugins",
}
h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody)
h.GetPermissions(c)
require.Equal(t, http.StatusOK, w.Code)
var payload struct {
Paths []map[string]any `json:"paths"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.NotEmpty(t, payload.Paths)
first := payload.Paths[0]
require.NotEmpty(t, first["path"])
require.NotEmpty(t, first["required"])
require.NotEmpty(t, first["mode"])
}
func TestSystemPermissionsHandler_GetPermissions_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{}
h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "user")
c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody)
h.GetPermissions(c)
require.Equal(t, http.StatusForbidden, w.Code)
var payload map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "permissions_admin_only", payload["error_code"])
}
func TestSystemPermissionsHandler_RepairPermissions_NonRoot(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("test requires non-root execution")
}
gin.SetMode(gin.TestMode)
cfg := config.Config{SingleContainer: true}
h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", http.NoBody)
h.RepairPermissions(c)
require.Equal(t, http.StatusForbidden, w.Code)
var payload map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Equal(t, "permissions_non_root", payload["error_code"])
}

View File

@@ -157,7 +157,8 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
// Backup routes
backupService := services.NewBackupService(&cfg)
backupService.Start() // Start cron scheduler for scheduled backups
backupHandler := handlers.NewBackupHandler(backupService)
securityService := services.NewSecurityService(db)
backupHandler := handlers.NewBackupHandlerWithDeps(backupService, securityService)
// DB Health endpoint (uses backup service for last backup time)
dbHealthHandler := handlers.NewDBHealthHandler(db, backupService)
@@ -221,20 +222,26 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
protected.GET("/websocket/connections", wsStatusHandler.GetConnections)
protected.GET("/websocket/stats", wsStatusHandler.GetStats)
dataRoot := filepath.Dir(cfg.DatabasePath)
// Security Notification Settings
securityNotificationService := services.NewSecurityNotificationService(db)
securityNotificationHandler := handlers.NewSecurityNotificationHandler(securityNotificationService)
securityNotificationHandler := handlers.NewSecurityNotificationHandlerWithDeps(securityNotificationService, securityService, dataRoot)
protected.GET("/security/notifications/settings", securityNotificationHandler.GetSettings)
protected.PUT("/security/notifications/settings", securityNotificationHandler.UpdateSettings)
// System permissions diagnostics and repair
systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil)
protected.GET("/system/permissions", systemPermissionsHandler.GetPermissions)
protected.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions)
// Audit Logs
securityService := services.NewSecurityService(db)
auditLogHandler := handlers.NewAuditLogHandler(securityService)
protected.GET("/audit-logs", auditLogHandler.List)
protected.GET("/audit-logs/:uuid", auditLogHandler.Get)
// Settings - with CaddyManager and Cerberus for security settings reload
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb)
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot)
protected.GET("/settings", settingsHandler.GetSettings)
protected.POST("/settings", settingsHandler.UpdateSetting)
@@ -387,7 +394,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
protected.POST("/uptime/sync", uptimeHandler.Sync)
// Notification Providers
notificationProviderHandler := handlers.NewNotificationProviderHandler(notificationService)
notificationProviderHandler := handlers.NewNotificationProviderHandlerWithDeps(notificationService, securityService, dataRoot)
protected.GET("/notifications/providers", notificationProviderHandler.List)
protected.POST("/notifications/providers", notificationProviderHandler.Create)
protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
@@ -397,7 +404,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
protected.GET("/notifications/templates", notificationProviderHandler.Templates)
// External notification templates (saved templates for providers)
notificationTemplateHandler := handlers.NewNotificationTemplateHandler(notificationService)
notificationTemplateHandler := handlers.NewNotificationTemplateHandlerWithDeps(notificationService, securityService, dataRoot)
protected.GET("/notifications/external-templates", notificationTemplateHandler.List)
protected.POST("/notifications/external-templates", notificationTemplateHandler.Create)
protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
@@ -640,7 +647,8 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
// RegisterImportHandler wires up import routes with config dependencies.
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir, mountPath)
securityService := services.NewSecurityService(db)
importHandler := handlers.NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, securityService)
api := router.Group("/api/v1")
importHandler.RegisterRoutes(api)

View File

@@ -14,6 +14,7 @@ type Config struct {
Environment string
HTTPPort string
DatabasePath string
ConfigRoot string
FrontendDir string
CaddyAdminAPI string
CaddyConfigDir string
@@ -23,6 +24,10 @@ type Config struct {
JWTSecret string
EncryptionKey string
ACMEStaging bool
SingleContainer bool
PluginsDir string
CaddyLogDir string
CrowdSecLogDir string
Debug bool
Security SecurityConfig
Emergency EmergencyConfig
@@ -82,6 +87,7 @@ func Load() (Config, error) {
Environment: getEnvAny("development", "CHARON_ENV", "CPM_ENV"),
HTTPPort: getEnvAny("8080", "CHARON_HTTP_PORT", "CPM_HTTP_PORT"),
DatabasePath: getEnvAny(filepath.Join("data", "charon.db"), "CHARON_DB_PATH", "CPM_DB_PATH"),
ConfigRoot: getEnvAny("/config", "CHARON_CADDY_CONFIG_ROOT"),
FrontendDir: getEnvAny(filepath.Clean(filepath.Join("..", "frontend", "dist")), "CHARON_FRONTEND_DIR", "CPM_FRONTEND_DIR"),
CaddyAdminAPI: getEnvAny("http://localhost:2019", "CHARON_CADDY_ADMIN_API", "CPM_CADDY_ADMIN_API"),
CaddyConfigDir: getEnvAny(filepath.Join("data", "caddy"), "CHARON_CADDY_CONFIG_DIR", "CPM_CADDY_CONFIG_DIR"),
@@ -91,6 +97,10 @@ func Load() (Config, error) {
JWTSecret: getEnvAny("change-me-in-production", "CHARON_JWT_SECRET", "CPM_JWT_SECRET"),
EncryptionKey: getEnvAny("", "CHARON_ENCRYPTION_KEY"),
ACMEStaging: getEnvAny("", "CHARON_ACME_STAGING", "CPM_ACME_STAGING") == "true",
SingleContainer: strings.EqualFold(getEnvAny("true", "CHARON_SINGLE_CONTAINER_MODE"), "true"),
PluginsDir: getEnvAny("/app/plugins", "CHARON_PLUGINS_DIR"),
CaddyLogDir: getEnvAny("/var/log/caddy", "CHARON_CADDY_LOG_DIR"),
CrowdSecLogDir: getEnvAny("/var/log/crowdsec", "CHARON_CROWDSEC_LOG_DIR"),
Security: loadSecurityConfig(),
Emergency: loadEmergencyConfig(),
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",

View File

@@ -9,14 +9,16 @@ import (
// NotificationConfig stores configuration for security notifications.
type NotificationConfig struct {
ID string `gorm:"primaryKey" json:"id"`
Enabled bool `json:"enabled"`
MinLogLevel string `json:"min_log_level"` // error, warn, info, debug
WebhookURL string `json:"webhook_url"`
NotifyWAFBlocks bool `json:"notify_waf_blocks"`
NotifyACLDenies bool `json:"notify_acl_denies"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `gorm:"primaryKey" json:"id"`
Enabled bool `json:"enabled"`
MinLogLevel string `json:"min_log_level"` // error, warn, info, debug
WebhookURL string `json:"webhook_url"`
NotifyWAFBlocks bool `json:"notify_waf_blocks"`
NotifyACLDenies bool `json:"notify_acl_denies"`
NotifyRateLimitHits bool `json:"notify_rate_limit_hits"`
EmailRecipients string `json:"email_recipients"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate sets the ID if not already set.

View File

@@ -17,13 +17,14 @@ import (
)
type LogService struct {
LogDir string
LogDir string
CaddyLogDir string
}
func NewLogService(cfg *config.Config) *LogService {
// Assuming logs are in data/logs relative to app root
logDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "logs")
return &LogService{LogDir: logDir}
return &LogService{LogDir: logDir, CaddyLogDir: cfg.CaddyLogDir}
}
func (s *LogService) logDirs() []string {
@@ -42,15 +43,14 @@ func (s *LogService) logDirs() []string {
}
addDir(s.LogDir)
if s.CaddyLogDir != "" {
addDir(s.CaddyLogDir)
}
if accessLogPath := os.Getenv("CHARON_CADDY_ACCESS_LOG"); accessLogPath != "" {
addDir(filepath.Dir(accessLogPath))
}
if _, err := os.Stat("/var/log/caddy"); err == nil {
addDir("/var/log/caddy")
}
return dirs
}

View File

@@ -33,10 +33,12 @@ func (s *SecurityNotificationService) GetSettings() (*models.NotificationConfig,
if err == gorm.ErrRecordNotFound {
// Return default config if none exists
return &models.NotificationConfig{
Enabled: false,
MinLogLevel: "error",
NotifyWAFBlocks: true,
NotifyACLDenies: true,
Enabled: false,
MinLogLevel: "error",
NotifyWAFBlocks: true,
NotifyACLDenies: true,
NotifyRateLimitHits: true,
EmailRecipients: "",
}, nil
}
return &config, err

View File

@@ -0,0 +1,151 @@
package util
import (
"errors"
"fmt"
"os"
"strings"
"syscall"
)
type PermissionCheck struct {
Path string `json:"path"`
Required string `json:"required"`
Exists bool `json:"exists"`
Writable bool `json:"writable"`
OwnerUID int `json:"owner_uid"`
OwnerGID int `json:"owner_gid"`
Mode string `json:"mode"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
func CheckPathPermissions(path, required string) PermissionCheck {
result := PermissionCheck{
Path: path,
Required: required,
}
info, err := os.Stat(path)
if err != nil {
result.Writable = false
result.Error = err.Error()
result.ErrorCode = MapDiagnosticErrorCode(err)
return result
}
result.Exists = true
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
result.OwnerUID = int(stat.Uid)
result.OwnerGID = int(stat.Gid)
}
result.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
if !info.IsDir() && !info.Mode().IsRegular() {
result.Writable = false
result.Error = "unsupported file type"
result.ErrorCode = "permissions_unsupported_type"
return result
}
if strings.Contains(required, "w") {
if info.IsDir() {
probeFile, probeErr := os.CreateTemp(path, "permcheck-*")
if probeErr != nil {
result.Writable = false
result.Error = probeErr.Error()
result.ErrorCode = MapDiagnosticErrorCode(probeErr)
return result
}
if closeErr := probeFile.Close(); closeErr != nil {
result.Writable = false
result.Error = closeErr.Error()
result.ErrorCode = MapDiagnosticErrorCode(closeErr)
return result
}
if removeErr := os.Remove(probeFile.Name()); removeErr != nil {
result.Writable = false
result.Error = removeErr.Error()
result.ErrorCode = MapDiagnosticErrorCode(removeErr)
return result
}
result.Writable = true
return result
}
file, openErr := os.OpenFile(path, os.O_WRONLY, 0)
if openErr != nil {
result.Writable = false
result.Error = openErr.Error()
result.ErrorCode = MapDiagnosticErrorCode(openErr)
return result
}
if closeErr := file.Close(); closeErr != nil {
result.Writable = false
result.Error = closeErr.Error()
result.ErrorCode = MapDiagnosticErrorCode(closeErr)
return result
}
result.Writable = true
return result
}
result.Writable = false
return result
}
func MapDiagnosticErrorCode(err error) string {
switch {
case err == nil:
return ""
case os.IsNotExist(err):
return "permissions_missing_path"
case errors.Is(err, syscall.EROFS):
return "permissions_readonly"
case errors.Is(err, syscall.EACCES) || os.IsPermission(err):
return "permissions_write_denied"
default:
return "permissions_write_failed"
}
}
func MapSaveErrorCode(err error) (string, bool) {
switch {
case err == nil:
return "", false
case IsSQLiteReadOnlyError(err):
return "permissions_db_readonly", true
case IsSQLiteLockedError(err):
return "permissions_db_locked", true
case errors.Is(err, syscall.EROFS):
return "permissions_readonly", true
case errors.Is(err, syscall.EACCES) || os.IsPermission(err):
return "permissions_write_denied", true
case strings.Contains(strings.ToLower(err.Error()), "permission denied"):
return "permissions_write_denied", true
default:
return "", false
}
}
func IsSQLiteReadOnlyError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "readonly") ||
strings.Contains(msg, "read-only") ||
strings.Contains(msg, "attempt to write a readonly database") ||
strings.Contains(msg, "sqlite_readonly")
}
func IsSQLiteLockedError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "database is locked") ||
strings.Contains(msg, "sqlite_busy") ||
strings.Contains(msg, "database locked")
}

View File

@@ -0,0 +1,57 @@
package util
import (
"errors"
"fmt"
"syscall"
"testing"
)
func TestMapSaveErrorCode(t *testing.T) {
tests := []struct {
name string
err error
wantCode string
wantOK bool
}{
{
name: "sqlite readonly",
err: errors.New("attempt to write a readonly database"),
wantCode: "permissions_db_readonly",
wantOK: true,
},
{
name: "sqlite locked",
err: errors.New("database is locked"),
wantCode: "permissions_db_locked",
wantOK: true,
},
{
name: "permission denied",
err: fmt.Errorf("write failed: %w", syscall.EACCES),
wantCode: "permissions_write_denied",
wantOK: true,
},
{
name: "not a permission error",
err: errors.New("other error"),
wantCode: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, ok := MapSaveErrorCode(tt.err)
if code != tt.wantCode || ok != tt.wantOK {
t.Fatalf("MapSaveErrorCode() = (%q, %v), want (%q, %v)", code, ok, tt.wantCode, tt.wantOK)
}
})
}
}
func TestIsSQLiteReadOnlyError(t *testing.T) {
if !IsSQLiteReadOnlyError(errors.New("SQLITE_READONLY")) {
t.Fatalf("expected SQLITE_READONLY to be detected")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
go 1.26.0
go 1.25.0
use ./backend

View File

@@ -920,14 +920,14 @@ test.describe('Notification Providers', () => {
* Test: Edit external template
* Priority: P2
*/
test('should edit external template', async ({ page }) => {
await test.step('Mock external templates API response', async () => {
await page.route('**/api/v1/notifications/external-templates', async (route, request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
test('should edit external template', async ({ page }) => {
await test.step('Mock external templates API response', async () => {
await page.route(/\/api\/v1\/notifications\/external-templates\/?(?:\?.*)?$/, async (route, request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 'edit-template-id',
name: 'Editable Template',
@@ -936,7 +936,7 @@ test.describe('Notification Providers', () => {
config: '{"old": "config"}',
},
]),
});
});
} else {
await route.continue();
}
@@ -948,15 +948,13 @@ test.describe('Notification Providers', () => {
await waitForLoadingComplete(page);
});
await test.step('Click Manage Templates button to show templates list', async () => {
// Find the toggle button for template management
const allButtons = page.getByRole('button');
const manageBtn = allButtons.filter({ hasText: /manage.*templates/i }).first();
if (await manageBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await manageBtn.click();
}
});
await test.step('Click Manage Templates button to show templates list', async () => {
// Find the toggle button for template management
const allButtons = page.getByRole('button');
const manageBtn = allButtons.filter({ hasText: /manage.*templates/i }).first();
await expect(manageBtn).toBeVisible({ timeout: 5000 });
await manageBtn.click();
});
await test.step('Wait and verify templates list is visible', async () => {
const templateText = page.getByText('Editable Template');
@@ -1013,9 +1011,9 @@ test.describe('Notification Providers', () => {
* Test: Delete external template
* Priority: P2
*/
test('should delete external template', async ({ page }) => {
await test.step('Mock external templates', async () => {
let templates = [
test('should delete external template', async ({ page }) => {
await test.step('Mock external templates', async () => {
let templates = [
{
id: 'delete-template-id',
name: 'Template to Delete',
@@ -1025,11 +1023,11 @@ test.describe('Notification Providers', () => {
},
];
await page.route('**/api/v1/notifications/external-templates', async (route, request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
await page.route(/\/api\/v1\/notifications\/external-templates\/?(?:\?.*)?$/, async (route, request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(templates),
});
return;
@@ -1053,13 +1051,13 @@ test.describe('Notification Providers', () => {
});
});
await test.step('Reload page', async () => {
// Wait for external templates fetch so list render is deterministic.
const templatesResponsePromise = waitForAPIResponse(
page,
/\/api\/v1\/notifications\/external-templates$/,
{ status: 200 }
);
await test.step('Reload page', async () => {
// Wait for external templates fetch so list render is deterministic.
const templatesResponsePromise = waitForAPIResponse(
page,
/\/api\/v1\/notifications\/external-templates\/?(?:\?.*)?$/,
{ status: 200 }
);
await page.reload();
await templatesResponsePromise;
@@ -1089,11 +1087,11 @@ test.describe('Notification Providers', () => {
/\/api\/v1\/notifications\/external-templates\/delete-template-id/,
{ status: 200 }
);
const refreshResponsePromise = waitForAPIResponse(
page,
/\/api\/v1\/notifications\/external-templates$/,
{ status: 200 }
);
const refreshResponsePromise = waitForAPIResponse(
page,
/\/api\/v1\/notifications\/external-templates\/?(?:\?.*)?$/,
{ status: 200 }
);
const templateHeading = page.getByRole('heading', { name: 'Template to Delete', level: 4 });
const templateCard = templateHeading.locator('..').locator('..');

View File

@@ -12,7 +12,7 @@
* @see /projects/Charon/frontend/src/pages/UsersPage.tsx
*/
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import { test, expect, loginUser, logoutUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import {
waitForLoadingComplete,
waitForToast,
@@ -409,26 +409,46 @@ test.describe('User Management', () => {
* Priority: P1
*/
test('should show invite URL preview', async ({ page }) => {
await test.step('Open invite modal', async () => {
const inviteModal = await test.step('Open invite modal', async () => {
const inviteButton = page.getByRole('button', { name: /invite.*user/i });
await inviteButton.click();
return await waitForModal(page, /invite.*user/i);
});
await test.step('Enter valid email', async () => {
const emailInput = page.getByPlaceholder(/user@example/i);
// Debounced API call triggers invite URL preview
const previewResponsePromise = waitForAPIResponse(
page,
/\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/,
{ status: 200 }
);
const emailInput = inviteModal.getByPlaceholder(/user@example/i);
await emailInput.fill('preview-test@example.com');
await previewResponsePromise;
});
await test.step('Wait for URL preview to appear', async () => {
// URL preview appears after debounced API call
// Wait for invite generation (triggers after email is filled)
await page.waitForTimeout(500);
// When app.public_url is not configured, the backend returns an empty preview URL
// and the UI shows a warning with a link to system settings.
const warningAlert = inviteModal
.getByRole('alert')
.filter({ hasText: /application url is not configured/i })
.first();
const previewUrlText = inviteModal
.locator('div.font-mono')
.filter({ hasText: /accept-invite\?token=/i })
.first();
const urlPreview = page.locator('input[readonly]').filter({
hasText: /accept.*invite|token/i,
});
await expect(warningAlert.or(previewUrlText).first()).toBeVisible({ timeout: 5000 });
await expect(urlPreview.first()).toBeVisible({ timeout: 5000 });
if (await warningAlert.isVisible().catch(() => false)) {
const configureLink = warningAlert.getByRole('link', { name: /configure.*application url/i });
await expect(configureLink).toBeVisible();
await expect(configureLink).toHaveAttribute('href', '/settings/system');
} else {
await expect(previewUrlText).toBeVisible();
}
});
});
@@ -635,37 +655,37 @@ test.describe('User Management', () => {
* Test: Add permitted hosts
* Priority: P0
*/
test('should add permitted hosts', async ({ page, testData }) => {
// SKIP: Depends on settings (permissions) button which is not yet implemented
const testUser = await testData.createUser({
name: 'Add Hosts Test',
email: `add-hosts-${Date.now()}@test.local`,
password: TEST_PASSWORD,
role: 'user',
});
test('should add permitted hosts', async ({ page, testData }) => {
// SKIP: Depends on settings (permissions) button which is not yet implemented
const testUser = await testData.createUser({
name: 'Add Hosts Test',
email: `add-hosts-${Date.now()}@test.local`,
password: TEST_PASSWORD,
role: 'user',
});
await test.step('Open permissions modal', async () => {
await page.reload();
await waitForLoadingComplete(page);
const permissionsModal = await test.step('Open permissions modal', async () => {
await page.reload();
await waitForLoadingComplete(page);
const userRow = page.getByRole('row').filter({
hasText: testUser.email,
const userRow = page.getByRole('row').filter({
hasText: testUser.email,
});
const permissionsButton = userRow.locator('button').filter({
has: page.locator('svg.lucide-settings'),
});
});
await permissionsButton.first().click();
await page.waitForTimeout(500);
});
await permissionsButton.first().click();
return await waitForModal(page, /permissions/i);
});
await test.step('Check a host to add', async () => {
const hostCheckboxes = page.locator('input[type="checkbox"]');
const count = await hostCheckboxes.count();
await test.step('Check a host to add', async () => {
const hostCheckboxes = permissionsModal.locator('input[type="checkbox"]');
const count = await hostCheckboxes.count();
if (count === 0) {
// No hosts to add - return
if (count === 0) {
// No hosts to add - return
return;
}
@@ -674,12 +694,12 @@ test.describe('User Management', () => {
await firstCheckbox.check();
}
await expect(firstCheckbox).toBeChecked();
});
});
await test.step('Save changes', async () => {
const saveButton = page.getByRole('button', { name: /save/i });
await saveButton.click();
});
await test.step('Save changes', async () => {
const saveButton = permissionsModal.getByRole('button', { name: /save/i });
await saveButton.click();
});
await test.step('Verify success', async () => {
await waitForToast(page, /updated|saved|success/i, { type: 'success' });
@@ -690,36 +710,36 @@ test.describe('User Management', () => {
* Test: Remove permitted hosts
* Priority: P1
*/
test('should remove permitted hosts', async ({ page, testData }) => {
const testUser = await testData.createUser({
name: 'Remove Hosts Test',
email: `remove-hosts-${Date.now()}@test.local`,
password: TEST_PASSWORD,
role: 'user',
});
test('should remove permitted hosts', async ({ page, testData }) => {
const testUser = await testData.createUser({
name: 'Remove Hosts Test',
email: `remove-hosts-${Date.now()}@test.local`,
password: TEST_PASSWORD,
role: 'user',
});
await test.step('Open permissions modal', async () => {
await page.reload();
await waitForLoadingComplete(page);
const permissionsModal = await test.step('Open permissions modal', async () => {
await page.reload();
await waitForLoadingComplete(page);
const userRow = page.getByRole('row').filter({
hasText: testUser.email,
const userRow = page.getByRole('row').filter({
hasText: testUser.email,
});
const permissionsButton = userRow.locator('button').filter({
has: page.locator('svg.lucide-settings'),
});
});
await permissionsButton.first().click();
await page.waitForTimeout(500);
});
await permissionsButton.first().click();
return await waitForModal(page, /permissions/i);
});
await test.step('Uncheck a checked host', async () => {
const hostCheckboxes = page.locator('input[type="checkbox"]');
const count = await hostCheckboxes.count();
await test.step('Uncheck a checked host', async () => {
const hostCheckboxes = permissionsModal.locator('input[type="checkbox"]');
const count = await hostCheckboxes.count();
if (count === 0) {
return;
if (count === 0) {
return;
}
// First check a box, then uncheck it
@@ -733,12 +753,12 @@ test.describe('User Management', () => {
await firstCheckbox.uncheck();
await expect(firstCheckbox).not.toBeChecked();
});
});
await test.step('Save changes', async () => {
const saveButton = page.getByRole('button', { name: /save/i });
await saveButton.click();
});
await test.step('Save changes', async () => {
const saveButton = permissionsModal.getByRole('button', { name: /save/i });
await saveButton.click();
});
await test.step('Verify success', async () => {
await waitForToast(page, /updated|saved|success/i, { type: 'success' });
@@ -1129,12 +1149,7 @@ test.describe('User Management', () => {
// Skip: Admin access control is enforced via routing/middleware, not visible error messages
test('should require admin role for access', async ({ page, regularUser }) => {
await test.step('Logout current admin', async () => {
// Navigate to logout or click logout button
const logoutButton = page.getByText(/logout/i);
if (await logoutButton.isVisible()) {
await logoutButton.click();
await page.waitForURL(/\/login/);
}
await logoutUser(page);
});
await test.step('Login as regular user', async () => {
@@ -1142,18 +1157,30 @@ test.describe('User Management', () => {
await waitForLoadingComplete(page);
});
await test.step('Attempt to access users page', async () => {
const listUsersResponse = await test.step('Attempt to access users page', async () => {
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'GET' &&
/\/api\/v1\/users\/?(?:\?.*)?$/.test(response.url()) &&
!/\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/.test(response.url()),
{ timeout: 15000 }
).catch(() => null);
await page.goto('/users', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
return await responsePromise;
});
await test.step('Verify access denied or redirect', async () => {
// Should either redirect to home/dashboard or show error
const currentUrl = page.url();
const isRedirected = !currentUrl.includes('/users');
const hasError = await page.getByText(/access.*denied|not.*authorized|forbidden/i).isVisible({ timeout: 3000 }).catch(() => false);
const hasForbiddenResponse = listUsersResponse?.status() === 403;
const hasError = await page
.getByText(/admin access required|access.*denied|not.*authorized|forbidden/i)
.isVisible({ timeout: 3000 })
.catch(() => false);
expect(isRedirected || hasError).toBeTruthy();
expect(isRedirected || hasForbiddenResponse || hasError).toBeTruthy();
});
});
@@ -1164,29 +1191,34 @@ test.describe('User Management', () => {
// Skip: Admin access control is enforced via routing/middleware, not visible error messages
test('should show error for regular user access', async ({ page, regularUser }) => {
await test.step('Logout and login as regular user', async () => {
const logoutButton = page.getByText(/logout/i);
if (await logoutButton.isVisible()) {
await logoutButton.click();
await page.waitForURL(/\/login/);
}
await logoutUser(page);
await loginUser(page, regularUser);
await waitForLoadingComplete(page);
});
await test.step('Navigate to users page directly', async () => {
const listUsersResponse = await test.step('Navigate to users page directly', async () => {
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'GET' &&
/\/api\/v1\/users\/?(?:\?.*)?$/.test(response.url()) &&
!/\/api\/v1\/users\/preview-invite-url\/?(?:\?.*)?$/.test(response.url()),
{ timeout: 15000 }
).catch(() => null);
await page.goto('/users', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
return await responsePromise;
});
await test.step('Verify error message or redirect', async () => {
// Check for error toast, error page, or redirect
const errorMessage = page.getByText(/access.*denied|unauthorized|forbidden|permission/i);
const errorMessage = page.getByText(/admin access required|access.*denied|unauthorized|forbidden|permission/i);
const hasError = await errorMessage.isVisible({ timeout: 3000 }).catch(() => false);
const isRedirected = !page.url().includes('/users');
const hasForbiddenResponse = listUsersResponse?.status() === 403;
expect(hasError || isRedirected).toBeTruthy();
expect(hasError || isRedirected || hasForbiddenResponse).toBeTruthy();
});
});

View File

@@ -194,20 +194,20 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await expect(page.getByTestId('log-file-list')).toBeVisible();
});
test('should show list of available log files', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should show list of available log files', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
const logFilesPromise = waitForAPIResponse(page, '/api/v1/logs', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for API response
await waitForAPIResponse(page, '/api/v1/logs', { status: 200 });
await logFilesPromise;
// Verify all log files are displayed in the list
await expect(page.getByText('access.log')).toBeVisible();
await expect(page.getByText('error.log')).toBeVisible();
await expect(page.getByText('caddy.log')).toBeVisible();
// Verify all log files are displayed in the list
await expect(page.getByText('access.log')).toBeVisible();
await expect(page.getByText('error.log')).toBeVisible();
await expect(page.getByText('caddy.log')).toBeVisible();
});
test('should display log filters section', async ({ page, authenticatedUser }) => {
@@ -242,35 +242,37 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await expect(page.getByText('0.24 MB')).toBeVisible();
});
test('should load log content when file selected', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should load log content when file selected', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// The first file (access.log) is auto-selected - wait for content
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
// The first file (access.log) is auto-selected - wait for content
await initialContentPromise;
// Verify log table is displayed
await expect(page.getByTestId('log-table')).toBeVisible();
});
});
// Verify log table is displayed
await expect(page.getByTestId('log-table')).toBeVisible();
});
});
test.describe('Log Content Display', () => {
test('should display log entries in table format', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should display log entries in table format', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for auto-selected log content to load
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
// Wait for auto-selected log content to load
await initialContentPromise;
// Verify table structure
const logTable = page.getByTestId('log-table');
await expect(logTable).toBeVisible();
// Verify table structure
const logTable = page.getByTestId('log-table');
await expect(logTable).toBeVisible();
// Verify table has expected columns
await expect(page.getByRole('columnheader', { name: /time/i })).toBeVisible();
@@ -278,35 +280,38 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await expect(page.getByRole('columnheader', { name: /method/i })).toBeVisible();
});
test('should show timestamp, level, method, uri, status', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should show timestamp, level, method, uri, status', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
// Wait for content to load
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
// Wait for content to load
await initialContentPromise;
// Verify log entry content is displayed
// The mock data includes 192.168.1.100 as remote_ip in first entry
await expect(page.getByText('192.168.1.100')).toBeVisible();
await expect(page.getByText('GET')).toBeVisible();
await expect(page.getByText('/api/v1/users')).toBeVisible();
await expect(page.getByTestId('status-200')).toBeVisible();
// Verify log entry content is displayed
// The mock data includes 192.168.1.100 as remote_ip in first entry
const entryRow = page.getByRole('row').filter({ hasText: '192.168.1.100' }).first();
await expect(entryRow).toBeVisible();
await expect(entryRow.getByRole('cell', { name: 'GET' })).toBeVisible();
await expect(entryRow.getByText('/api/v1/users')).toBeVisible();
await expect(entryRow.getByTestId('status-200')).toBeVisible();
});
test('should highlight error entries with distinct styling', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should highlight error entries with distinct styling', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Find the 502 error status badge - should have red styling class
const errorStatus = page.getByTestId('status-502');
await expect(errorStatus).toBeVisible();
// Find the 502 error status badge - should have red styling class
const errorStatus = page.getByTestId('status-502');
await expect(errorStatus).toBeVisible();
// Verify error has red styling (bg-red or similar)
await expect(errorStatus).toHaveClass(/red/);
@@ -342,9 +347,11 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
});
});
// Set up the response wait BEFORE navigation to avoid missing fast mocked responses.
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await initialContentPromise;
// Initial state - page 1
expect(capturedOffset).toBe(0);
@@ -363,8 +370,8 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
expect(capturedOffset).toBe(50);
});
test('should display page info correctly', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should display page info correctly', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
const largeEntrySet = generateMockEntries(150);
@@ -372,7 +379,7 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
@@ -387,22 +394,23 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
offset,
},
});
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify page info displays correctly
const pageInfo = page.getByTestId('page-info');
await expect(pageInfo).toBeVisible();
// Verify page info displays correctly
const pageInfo = page.getByTestId('page-info');
await expect(pageInfo).toBeVisible();
// Should show "Showing 1 - 50 of 150" or similar format
await expect(pageInfo).toContainText(/1.*50.*150/);
});
test('should disable prev button on first page and next on last', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should disable prev button on first page and next on last', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
const entries = generateMockEntries(75); // 2 pages (50 + 25)
@@ -410,7 +418,7 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
const offset = parseInt(url.searchParams.get('offset') || '0');
const limit = parseInt(url.searchParams.get('limit') || '50');
@@ -425,11 +433,12 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
offset,
},
});
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
const prevButton = getPrevButton(page);
const nextButton = getNextButton(page);
@@ -451,8 +460,8 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
});
test.describe('Search and Filter', () => {
test('should filter logs by search text', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should filter logs by search text', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
let capturedSearch = '';
@@ -460,7 +469,7 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
capturedSearch = url.searchParams.get('search') || '';
@@ -483,11 +492,12 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
offset: 0,
},
});
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Type in search input
const searchInput = page.getByTestId('search-input');
@@ -506,8 +516,8 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
expect(capturedSearch).toBe('users');
});
test('should filter logs by log level', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should filter logs by log level', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
let capturedLevel = '';
@@ -515,7 +525,7 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
const url = new URL(route.request().url());
capturedLevel = url.searchParams.get('level') || '';
@@ -534,11 +544,12 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
offset: 0,
},
});
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Select Error level from dropdown using data-testid
const levelSelect = page.getByTestId('level-select');
@@ -558,13 +569,14 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
});
test.describe('Download', () => {
test('should download log file successfully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
test('should download log file successfully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await setupLogMocks(page);
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify download button is visible and enabled
const downloadButton = page.getByTestId('download-button');
@@ -576,14 +588,14 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
await expect(downloadButton).not.toHaveAttribute('disabled');
});
test('should handle download error gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should handle download error gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
if (!route.request().url().includes('/download')) {
await route.fulfill({
status: 200,
@@ -598,11 +610,12 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
} else {
await route.continue();
}
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Verify download button is present and properly rendered
const downloadButton = page.getByTestId('download-button');
@@ -614,14 +627,14 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
});
test.describe('Edge Cases', () => {
test('should handle empty log content gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
test('should handle empty log content gracefully', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.route('**/api/v1/logs', async (route) => {
await route.fulfill({ status: 200, json: mockLogFiles });
});
await page.route('**/api/v1/logs/access.log*', async (route) => {
await page.route('**/api/v1/logs/access.log*', async (route) => {
await route.fulfill({
status: 200,
json: {
@@ -632,11 +645,12 @@ test.describe('Logs Page - WebKit Compatible Tests', () => {
offset: 0,
},
});
});
});
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
const initialContentPromise = waitForAPIResponse(page, '/api/v1/logs/access.log', { status: 200 });
await page.goto('/tasks/logs', { waitUntil: 'domcontentloaded' });
await waitForLoadingComplete(page);
await initialContentPromise;
// Should show "No logs found" or similar message
await expect(page.getByText(/no logs found|no.*matching/i)).toBeVisible();

View File

@@ -28,7 +28,7 @@
* ```
*/
import { APIRequestContext, type APIResponse } from '@playwright/test';
import { APIRequestContext, type APIResponse, request as playwrightRequest } from '@playwright/test';
import * as crypto from 'crypto';
/**
@@ -162,6 +162,7 @@ export class TestDataManager {
private resources: ManagedResource[] = [];
private namespace: string;
private request: APIRequestContext;
private baseURLPromise: Promise<string> | null = null;
/**
* Creates a new TestDataManager instance
@@ -176,6 +177,33 @@ export class TestDataManager {
: `test-${crypto.randomUUID()}`;
}
private async getBaseURL(): Promise<string> {
if (this.baseURLPromise) {
return await this.baseURLPromise;
}
this.baseURLPromise = (async () => {
const envBaseURL = process.env.PLAYWRIGHT_BASE_URL;
if (envBaseURL) {
try {
return new URL(envBaseURL).origin;
} catch {
return envBaseURL;
}
}
try {
const response = await this.request.get('/api/v1/health');
return new URL(response.url()).origin;
} catch {
// Default matches playwright.config.js non-coverage baseURL
return 'http://127.0.0.1:8080';
}
})();
return await this.baseURLPromise;
}
/**
* Sanitizes a test name for use in identifiers
* Keeps it short to avoid overly long domain names
@@ -474,20 +502,36 @@ export class TestDataManager {
createdAt: new Date(),
});
// Automatically log in the user and return token
const loginResponse = await this.request.post('/api/v1/auth/login', {
data: { email: namespacedEmail, password: data.password },
// Automatically log in the user and return token.
//
// IMPORTANT: Do NOT log in using the manager's request context.
// The request context is expected to remain admin-authenticated so later
// operations (and automatic cleanup) can delete resources regardless of
// the created user's role.
const loginContext = await playwrightRequest.newContext({
baseURL: await this.getBaseURL(),
extraHTTPHeaders: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
if (!loginResponse.ok()) {
// User created but login failed - still return user info
console.warn(`User created but login failed: ${await loginResponse.text()}`);
return { id: result.id, email: namespacedEmail, token: '' };
try {
const loginResponse = await loginContext.post('/api/v1/auth/login', {
data: { email: namespacedEmail, password: data.password },
});
if (!loginResponse.ok()) {
// User created but login failed - still return user info
console.warn(`User created but login failed: ${await loginResponse.text()}`);
return { id: result.id, email: namespacedEmail, token: '' };
}
const { token } = await loginResponse.json();
return { id: result.id, email: namespacedEmail, token };
} finally {
await loginContext.dispose();
}
const { token } = await loginResponse.json();
return { id: result.id, email: namespacedEmail, token };
}
/**