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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
110
backend/internal/api/handlers/permission_helpers.go
Normal file
110
backend/internal/api/handlers/permission_helpers.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
437
backend/internal/api/handlers/system_permissions_handler.go
Normal file
437
backend/internal/api/handlers/system_permissions_handler.go
Normal 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"
|
||||
}
|
||||
}
|
||||
107
backend/internal/api/handlers/system_permissions_handler_test.go
Normal file
107
backend/internal/api/handlers/system_permissions_handler_test.go
Normal 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"])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
151
backend/internal/util/permissions.go
Normal file
151
backend/internal/util/permissions.go
Normal 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")
|
||||
}
|
||||
57
backend/internal/util/permissions_test.go
Normal file
57
backend/internal/util/permissions_test.go
Normal 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
@@ -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('..');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user