Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
212 lines
6.6 KiB
Go
212 lines
6.6 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 containsPermission(permissions []string, wanted string) bool {
|
|
for _, permission := range permissions {
|
|
if permission == wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|