Files
Charon/backend/internal/api/handlers/system_permissions_handler_test.go
GitHub Actions e6c4e46dd8 chore: Refactor test setup for Gin framework
- Removed redundant `gin.SetMode(gin.TestMode)` calls from individual test files.
- Introduced a centralized `TestMain` function in `testmain_test.go` to set the Gin mode for all tests.
- Ensured consistent test environment setup across various handler test files.
2026-03-25 22:00:07 +00:00

595 lines
19 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"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"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type stubPermissionChecker struct{}
type fakeNoStatFileInfo struct{}
func (fakeNoStatFileInfo) Name() string { return "fake" }
func (fakeNoStatFileInfo) Size() int64 { return 0 }
func (fakeNoStatFileInfo) Mode() os.FileMode { return 0 }
func (fakeNoStatFileInfo) ModTime() time.Time { return time.Time{} }
func (fakeNoStatFileInfo) IsDir() bool { return false }
func (fakeNoStatFileInfo) Sys() any { return nil }
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) {
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) {
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")
}
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"])
}
func TestSystemPermissionsHandler_HelperFunctions(t *testing.T) {
t.Run("normalizePath", func(t *testing.T) {
clean, code := normalizePath("/tmp/example")
require.Equal(t, "/tmp/example", clean)
require.Empty(t, code)
clean, code = normalizePath("")
require.Empty(t, clean)
require.Equal(t, "permissions_invalid_path", code)
clean, code = normalizePath("relative/path")
require.Empty(t, clean)
require.Equal(t, "permissions_invalid_path", code)
})
t.Run("containsParentReference", func(t *testing.T) {
require.True(t, containsParentReference(".."))
require.True(t, containsParentReference("../secrets"))
require.True(t, containsParentReference("/var/../etc"))
require.True(t, containsParentReference("/var/log/.."))
require.False(t, containsParentReference("/var/log/charon"))
})
t.Run("isWithinAllowlist", func(t *testing.T) {
allowlist := []string{"/app/data", "/config"}
require.True(t, isWithinAllowlist("/app/data/charon.db", allowlist))
require.True(t, isWithinAllowlist("/config/caddy", allowlist))
require.False(t, isWithinAllowlist("/etc/passwd", allowlist))
})
t.Run("targetMode", func(t *testing.T) {
require.Equal(t, "0700", targetMode(true, false))
require.Equal(t, "0770", targetMode(true, true))
require.Equal(t, "0600", targetMode(false, false))
require.Equal(t, "0660", targetMode(false, true))
})
t.Run("parseMode", func(t *testing.T) {
mode, err := parseMode("0640")
require.NoError(t, err)
require.Equal(t, os.FileMode(0640), mode)
_, err = parseMode("")
require.Error(t, err)
_, err = parseMode("invalid")
require.Error(t, err)
})
t.Run("mapRepairErrorCode", func(t *testing.T) {
require.Equal(t, "", mapRepairErrorCode(nil))
require.Equal(t, "permissions_readonly", mapRepairErrorCode(syscall.EROFS))
require.Equal(t, "permissions_write_denied", mapRepairErrorCode(syscall.EACCES))
require.Equal(t, "permissions_repair_failed", mapRepairErrorCode(syscall.EINVAL))
})
}
func TestSystemPermissionsHandler_PathHasSymlink(t *testing.T) {
root := t.TempDir()
realDir := filepath.Join(root, "real")
require.NoError(t, os.Mkdir(realDir, 0o750))
plainPath := filepath.Join(realDir, "file.txt")
require.NoError(t, os.WriteFile(plainPath, []byte("ok"), 0o600))
hasSymlink, err := pathHasSymlink(plainPath)
require.NoError(t, err)
require.False(t, hasSymlink)
linkDir := filepath.Join(root, "link")
require.NoError(t, os.Symlink(realDir, linkDir))
symlinkedPath := filepath.Join(linkDir, "file.txt")
hasSymlink, err = pathHasSymlink(symlinkedPath)
require.NoError(t, err)
require.True(t, hasSymlink)
_, err = pathHasSymlink(filepath.Join(root, "missing", "file.txt"))
require.Error(t, err)
}
func TestSystemPermissionsHandler_NewDefaultsCheckerToOSChecker(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{}, nil, nil)
require.NotNil(t, h)
require.NotNil(t, h.checker)
}
func TestSystemPermissionsHandler_RepairPermissions_DisabledWhenNotSingleContainer(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{SingleContainer: false}, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":["/tmp"]}`))
c.Request.Header.Set("Content-Type", "application/json")
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_repair_disabled", payload["error_code"])
}
func TestSystemPermissionsHandler_RepairPermissions_InvalidJSON(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test requires root execution")
}
root := t.TempDir()
dataDir := filepath.Join(root, "data")
require.NoError(t, os.MkdirAll(dataDir, 0o750))
cfg := config.Config{
SingleContainer: true,
DatabasePath: filepath.Join(dataDir, "charon.db"),
ConfigRoot: dataDir,
CaddyLogDir: dataDir,
CrowdSecLogDir: dataDir,
PluginsDir: filepath.Join(root, "plugins"),
}
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", bytes.NewBufferString(`{"paths":`))
c.Request.Header.Set("Content-Type", "application/json")
h.RepairPermissions(c)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSystemPermissionsHandler_RepairPermissions_Success(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test requires root execution")
}
root := t.TempDir()
dataDir := filepath.Join(root, "data")
require.NoError(t, os.MkdirAll(dataDir, 0o750))
targetFile := filepath.Join(dataDir, "repair-target.txt")
require.NoError(t, os.WriteFile(targetFile, []byte("repair"), 0o600))
cfg := config.Config{
SingleContainer: true,
DatabasePath: filepath.Join(dataDir, "charon.db"),
ConfigRoot: dataDir,
CaddyLogDir: dataDir,
CrowdSecLogDir: dataDir,
PluginsDir: filepath.Join(root, "plugins"),
}
h := NewSystemPermissionsHandler(cfg, nil, stubPermissionChecker{})
body := fmt.Sprintf(`{"paths":[%q],"group_mode":false}`, targetFile)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.RepairPermissions(c)
require.Equal(t, http.StatusOK, w.Code)
var payload struct {
Paths []permissionsRepairResult `json:"paths"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.Len(t, payload.Paths, 1)
require.Equal(t, targetFile, payload.Paths[0].Path)
require.NotEqual(t, "error", payload.Paths[0].Status)
}
func TestSystemPermissionsHandler_RepairPermissions_NonAdmin(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{SingleContainer: true}, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "user")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":["/tmp"]}`))
c.Request.Header.Set("Content-Type", "application/json")
h.RepairPermissions(c)
require.Equal(t, http.StatusForbidden, w.Code)
}
func TestSystemPermissionsHandler_RepairPermissions_InvalidJSONWhenRoot(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test requires root execution")
}
root := t.TempDir()
dataDir := filepath.Join(root, "data")
require.NoError(t, os.MkdirAll(dataDir, 0o750))
h := NewSystemPermissionsHandler(config.Config{
SingleContainer: true,
DatabasePath: filepath.Join(dataDir, "charon.db"),
ConfigRoot: dataDir,
CaddyLogDir: dataDir,
CrowdSecLogDir: dataDir,
}, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":`))
c.Request.Header.Set("Content-Type", "application/json")
h.RepairPermissions(c)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSystemPermissionsHandler_DefaultPathsAndAllowlistRoots(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{
DatabasePath: "/app/data/charon.db",
ConfigRoot: "/app/config",
CaddyLogDir: "/var/log/caddy",
CrowdSecLogDir: "/var/log/crowdsec",
PluginsDir: "/app/plugins",
}, nil, stubPermissionChecker{})
paths := h.defaultPaths()
require.Len(t, paths, 11)
require.Equal(t, "/app/data", paths[0].Path)
require.Equal(t, "/app/plugins", paths[len(paths)-1].Path)
roots := h.allowlistRoots()
require.Equal(t, []string{"/app/data", "/app/config", "/var/log/caddy", "/var/log/crowdsec"}, roots)
}
func TestSystemPermissionsHandler_IsOwnedByFalseWhenSysNotStat(t *testing.T) {
owned := isOwnedBy(fakeNoStatFileInfo{}, os.Geteuid(), os.Getegid())
require.False(t, owned)
}
func TestSystemPermissionsHandler_IsWithinAllowlist_RelErrorBranch(t *testing.T) {
tmp := t.TempDir()
inAllow := filepath.Join(tmp, "a", "b")
require.NoError(t, os.MkdirAll(inAllow, 0o750))
badRoot := string([]byte{'/', 0, 'x'})
allowed := isWithinAllowlist(inAllow, []string{badRoot, tmp})
require.True(t, allowed)
}
func TestSystemPermissionsHandler_IsWithinAllowlist_AllRelErrorsReturnFalse(t *testing.T) {
badRoot1 := string([]byte{'/', 0, 'x'})
badRoot2 := string([]byte{'/', 0, 'y'})
allowed := isWithinAllowlist("/tmp/some/path", []string{badRoot1, badRoot2})
require.False(t, allowed)
}
func TestSystemPermissionsHandler_LogAudit_PersistsAuditWithUserID(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SecurityAudit{}))
securitySvc := services.NewSecurityService(db)
h := NewSystemPermissionsHandler(config.Config{}, securitySvc, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Set("userID", 42)
c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody)
require.NotPanics(t, func() {
h.logAudit(c, "permissions_diagnostics", "ok", "", 2)
})
}
func TestSystemPermissionsHandler_LogAudit_PersistsAuditWithUnknownActor(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SecurityAudit{}))
securitySvc := services.NewSecurityService(db)
h := NewSystemPermissionsHandler(config.Config{}, securitySvc, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody)
require.NotPanics(t, func() {
h.logAudit(c, "permissions_diagnostics", "ok", "", 1)
})
}
func TestSystemPermissionsHandler_RepairPath_Branches(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{})
allowRoot := t.TempDir()
allowlist := []string{allowRoot}
t.Run("invalid path", func(t *testing.T) {
result := h.repairPath("", false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_invalid_path", result.ErrorCode)
})
t.Run("missing path", func(t *testing.T) {
missingPath := filepath.Join(allowRoot, "missing-file.txt")
result := h.repairPath(missingPath, false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_missing_path", result.ErrorCode)
})
t.Run("symlink leaf rejected", func(t *testing.T) {
target := filepath.Join(allowRoot, "target.txt")
require.NoError(t, os.WriteFile(target, []byte("ok"), 0o600))
link := filepath.Join(allowRoot, "link.txt")
require.NoError(t, os.Symlink(target, link))
result := h.repairPath(link, false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_symlink_rejected", result.ErrorCode)
})
t.Run("symlink component rejected", func(t *testing.T) {
realDir := filepath.Join(allowRoot, "real")
require.NoError(t, os.MkdirAll(realDir, 0o750))
realFile := filepath.Join(realDir, "file.txt")
require.NoError(t, os.WriteFile(realFile, []byte("ok"), 0o600))
linkDir := filepath.Join(allowRoot, "linkdir")
require.NoError(t, os.Symlink(realDir, linkDir))
result := h.repairPath(filepath.Join(linkDir, "file.txt"), false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_symlink_rejected", result.ErrorCode)
})
t.Run("outside allowlist rejected", func(t *testing.T) {
outsideFile := filepath.Join(t.TempDir(), "outside.txt")
require.NoError(t, os.WriteFile(outsideFile, []byte("x"), 0o600))
result := h.repairPath(outsideFile, false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_outside_allowlist", result.ErrorCode)
})
t.Run("outside allowlist rejected before stat for missing path", func(t *testing.T) {
outsideMissing := filepath.Join(t.TempDir(), "missing.txt")
result := h.repairPath(outsideMissing, false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_outside_allowlist", result.ErrorCode)
})
t.Run("unsupported type rejected", func(t *testing.T) {
fifoPath := filepath.Join(allowRoot, "fifo")
require.NoError(t, syscall.Mkfifo(fifoPath, 0o600))
result := h.repairPath(fifoPath, false, allowlist)
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_unsupported_type", result.ErrorCode)
})
t.Run("already correct skipped", func(t *testing.T) {
filePath := filepath.Join(allowRoot, "already-correct.txt")
require.NoError(t, os.WriteFile(filePath, []byte("ok"), 0o600))
result := h.repairPath(filePath, false, allowlist)
require.Equal(t, "skipped", result.Status)
require.Equal(t, "permissions_repair_skipped", result.ErrorCode)
require.Equal(t, "0600", result.ModeAfter)
})
}
func TestSystemPermissionsHandler_OSChecker_Check(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test expects root-owned temp paths in CI")
}
tmp := t.TempDir()
filePath := filepath.Join(tmp, "check.txt")
require.NoError(t, os.WriteFile(filePath, []byte("ok"), 0o600))
checker := OSChecker{}
result := checker.Check(filePath, "rw")
require.Equal(t, filePath, result.Path)
require.Equal(t, "rw", result.Required)
require.True(t, result.Exists)
}
func TestSystemPermissionsHandler_RepairPermissions_InvalidRequestBody_Root(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test requires root execution")
}
tmp := t.TempDir()
dataDir := filepath.Join(tmp, "data")
require.NoError(t, os.MkdirAll(dataDir, 0o750))
h := NewSystemPermissionsHandler(config.Config{
SingleContainer: true,
DatabasePath: filepath.Join(dataDir, "charon.db"),
ConfigRoot: dataDir,
CaddyLogDir: dataDir,
CrowdSecLogDir: dataDir,
PluginsDir: filepath.Join(tmp, "plugins"),
}, nil, stubPermissionChecker{})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("role", "admin")
c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"group_mode":true}`))
c.Request.Header.Set("Content-Type", "application/json")
h.RepairPermissions(c)
require.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSystemPermissionsHandler_RepairPath_LstatInvalidArgument(t *testing.T) {
h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{})
allowRoot := t.TempDir()
result := h.repairPath("/tmp/\x00invalid", false, []string{allowRoot})
require.Equal(t, "error", result.Status)
require.Equal(t, "permissions_outside_allowlist", result.ErrorCode)
}
func TestSystemPermissionsHandler_RepairPath_RepairedBranch(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("test requires root execution")
}
h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{})
allowRoot := t.TempDir()
targetFile := filepath.Join(allowRoot, "needs-repair.txt")
require.NoError(t, os.WriteFile(targetFile, []byte("ok"), 0o600))
result := h.repairPath(targetFile, true, []string{allowRoot})
require.Equal(t, "repaired", result.Status)
require.Equal(t, "0660", result.ModeAfter)
info, err := os.Stat(targetFile)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o660), info.Mode().Perm())
}
func TestSystemPermissionsHandler_NormalizePath_ParentRefBranches(t *testing.T) {
clean, code := normalizePath("/../etc")
require.Equal(t, "/etc", clean)
require.Empty(t, code)
clean, code = normalizePath("/var/../etc")
require.Equal(t, "/etc", clean)
require.Empty(t, code)
}
func TestSystemPermissionsHandler_NormalizeAllowlist(t *testing.T) {
allowlist := normalizeAllowlist([]string{"", "/tmp/data/..", "/var/log/charon"})
require.Equal(t, []string{"/tmp", "/var/log/charon"}, allowlist)
}