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 }