Add activity logs, sidebar badges, and logout with working alert colors.
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.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user