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.
237 lines
7.5 KiB
Go
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
|
|
}
|