Files
ClusterCanvas/service/internal/api/auth_audit_test.go
T
Squid 7631591f30 Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
2026-07-19 22:00:14 +02:00

237 lines
7.5 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestAuthLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
// Strip logs.read and the permissions that would trigger migration.
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
failRec := httptest.NewRecorder()
router.ServeHTTP(failRec, failReq)
if failRec.Code != http.StatusUnauthorized {
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
}
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
okRec := httptest.NewRecorder()
router.ServeHTTP(okRec, okReq)
if okRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
}
var loginCookie *http.Cookie
for _, candidate := range okRec.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
loginCookie = candidate
break
}
}
if loginCookie == nil {
t.Fatal("login did not set session cookie")
}
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
),
loginCookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
}
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
}
var logs authLogsResponse
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) < 3 {
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
}
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
for _, event := range logs.Events {
switch {
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
sawLoginFailure = true
if event.Detail != "invalid credentials" {
t.Fatalf("failure detail = %q", event.Detail)
}
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
sawLoginSuccess = true
if event.Actor != "Admin" {
t.Fatalf("login actor = %q", event.Actor)
}
case event.Action == "user_create" && event.Target == "alice":
sawUserCreate = true
if event.Category != settings.AuthAuditCategoryUser {
t.Fatalf("category = %q", event.Category)
}
}
}
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
t.Fatalf(
"missing events: failure=%v success=%v create=%v events=%+v",
sawLoginFailure,
sawLoginSuccess,
sawUserCreate,
logs.Events,
)
}
// Newest first: user_create should appear before older login events.
if logs.Events[0].Action != "user_create" {
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
}
}
func TestMigrateLogsReadPermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
{Name: "hr", Permissions: []string{"users.manage"}},
{Name: "auditors", Permissions: []string{"logs.read"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateLogsReadPermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
t.Fatal("ops should gain logs.read from nodes.read")
}
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
t.Fatal("hr should gain logs.read from users.manage")
}
if len(payload.Groups[2].Permissions) != 1 {
t.Fatal("auditors should be unchanged")
}
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
t.Fatal("viewers should not gain logs.read")
}
if migrateLogsReadPermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
func TestMigrateActionsExecutePermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}},
{Name: "already", Permissions: []string{"jobs.run", "actions.execute"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateActionsExecutePermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "actions.execute") {
t.Fatal("runners should gain actions.execute from jobs.run")
}
if len(payload.Groups[1].Permissions) != 2 {
t.Fatal("already should be unchanged")
}
if containsPermission(payload.Groups[2].Permissions, "actions.execute") {
t.Fatal("viewers should not gain actions.execute")
}
if migrateActionsExecutePermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
func containsPermission(permissions []string, wanted string) bool {
for _, permission := range permissions {
if permission == wanted {
return true
}
}
return false
}