package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) type usersResponseTest struct { Users []userPublic `json:"users"` } func TestUsersGetListsSanitizedFields(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/users", nil), cookie) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) } rawBody := recorder.Body.Bytes() var payload usersResponseTest if err := json.Unmarshal(rawBody, &payload); err != nil { t.Fatalf("decode response: %v", err) } if len(payload.Users) != 1 { t.Fatalf("expected 1 user, got %d", len(payload.Users)) } user := payload.Users[0] if user.Username != "Admin" { t.Fatalf("expected username Admin, got %q", user.Username) } if !user.IsAdmin { t.Fatal("expected is_admin true") } if user.CanDelete { t.Fatal("expected can_delete false for sole admin") } if user.ID == "" || user.Username == "" { t.Fatal("expected id and username") } raw := string(rawBody) if strings.Contains(raw, "password_hash") || strings.Contains(raw, "totp_secret") { t.Fatalf("response leaked secrets: %s", raw) } } func TestUsersDeleteRejectsLastAdministrator(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) request := withSession( httptest.NewRequest( http.MethodDelete, "/api/v1/users?id=test-user-id", strings.NewReader(`{"current_password":"correct horse battery staple extra"}`), ), cookie, ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict { t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, recorder.Code, recorder.Body.String()) } var payload apiErrorResponseTest if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.Error != "cannot delete the last administrator" { t.Fatalf("unexpected error: %q", payload.Error) } } func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) key, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } store, err := settings.LoadPasswords(configDir, key) if err != nil { t.Fatalf("LoadPasswords: %v", err) } now := time.Now().UTC() passwordHash, err := auth.HashPassword("correct horse battery staple extra") if err != nil { t.Fatalf("HashPassword: %v", err) } store.Users = append(store.Users, settings.UserCredential{ ID: "operator-id", Username: "operator", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{"Operators"}, CreatedAt: now, PasswordChangedAt: now, }, settings.UserCredential{ ID: "admin-two-id", Username: "AdminTwo", PasswordHash: passwordHash, Enabled: true, GroupNames: []string{settings.AdministratorsGroupName}, CreatedAt: now, PasswordChangedAt: now, }, ) if err := settings.SavePasswords(configDir, store, key); err != nil { t.Fatalf("SavePasswords: %v", err) } router := NewRouter(configDir) reauthBody := `{"current_password":"correct horse battery staple extra"}` deleteOperator := withSession( httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)), cookie, ) deleteOperator.Header.Set("Content-Type", "application/json") operatorRec := httptest.NewRecorder() router.ServeHTTP(operatorRec, deleteOperator) if operatorRec.Code != http.StatusOK { t.Fatalf("delete operator: expected %d, got %d body=%s", http.StatusOK, operatorRec.Code, operatorRec.Body.String()) } deleteSecondAdmin := withSession( httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)), cookie, ) deleteSecondAdmin.Header.Set("Content-Type", "application/json") adminRec := httptest.NewRecorder() router.ServeHTTP(adminRec, deleteSecondAdmin) if adminRec.Code != http.StatusOK { t.Fatalf("delete second admin: expected %d, got %d body=%s", http.StatusOK, adminRec.Code, adminRec.Body.String()) } var payload usersResponseTest if err := json.NewDecoder(adminRec.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if len(payload.Users) != 1 { t.Fatalf("expected 1 remaining user, got %d", len(payload.Users)) } if payload.Users[0].ID != "test-user-id" { t.Fatalf("expected sole remaining admin id, got %q", payload.Users[0].ID) } if payload.Users[0].CanDelete { t.Fatal("expected remaining sole admin can_delete false") } } func TestUsersDeleteMissingIDFails(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) request := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/users", nil), cookie) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code) } } func TestUsersDeleteUnknownUserNotFound(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) request := withSession( httptest.NewRequest( http.MethodDelete, "/api/v1/users?id=missing", strings.NewReader(`{"current_password":"correct horse battery staple extra"}`), ), cookie, ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusNotFound { t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String()) } } func TestUsersCreateAndPatch(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) key, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } payload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } payload.Groups = append(payload.Groups, settings.Group{ Name: "Operators", ScopeKind: settings.GroupScopeGroup, ScopeName: "Ops", Permissions: []string{"nodes.read"}, }) if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } router := NewRouter(configDir) createReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/users", strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`), ), cookie, ) createReq.Header.Set("Content-Type", "application/json") createRec := httptest.NewRecorder() router.ServeHTTP(createRec, createReq) if createRec.Code != http.StatusOK { t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String()) } var created usersResponseTest if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil { t.Fatalf("decode create: %v", err) } var aliceID string for _, user := range created.Users { if user.Username == "alice" { aliceID = user.ID if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" { t.Fatalf("unexpected groups: %#v", user.GroupNames) } } } if aliceID == "" { t.Fatal("alice not found after create") } patchReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/users?id="+aliceID, strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`), ), cookie, ) patchReq.Header.Set("Content-Type", "application/json") patchRec := httptest.NewRecorder() router.ServeHTTP(patchRec, patchReq) if patchRec.Code != http.StatusOK { t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String()) } store, err := settings.LoadPasswords(configDir, key) if err != nil { t.Fatalf("LoadPasswords: %v", err) } for _, user := range store.Users { if user.ID != aliceID { continue } if user.TOTPConfirmed || user.TOTPSecret != "" { t.Fatal("expected TOTP cleared") } ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash) if err != nil || !ok { t.Fatal("expected password updated") } } } func TestUsersWriteRequiresPermission(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) key, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } payload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } // Strip users.manage from Administrators for index := range payload.Groups { if payload.Groups[index].Name != settings.AdministratorsGroupName { continue } filtered := make([]string, 0) for _, permission := range payload.Groups[index].Permissions { if permission == "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) } _ = key router := NewRouter(configDir) request := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/users", strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`), ), cookie, ) request.Header.Set("Content-Type", "application/json") 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 TestUsersPatchRequiresGraceReauth(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) key, err := settings.KeyFromEnv() if err != nil { t.Fatalf("KeyFromEnv: %v", err) } payload, err := settings.LoadSettings(configDir) if err != nil { t.Fatalf("LoadSettings: %v", err) } payload.Security.ReauthSensitiveActions = true payload.Security.ReauthGraceMinutes = 0 if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) } // Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran. store, err := settings.LoadSessionsOrEmpty(configDir, key) if err != nil { t.Fatalf("LoadSessions: %v", err) } for index := range store.Sessions { store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute) } if err := settings.SaveSessions(configDir, store, key); err != nil { t.Fatalf("SaveSessions: %v", err) } router := NewRouter(configDir) patchReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/users?id=test-user-id", strings.NewReader(`{"password":"another strong passphrase here"}`), ), cookie, ) patchReq.Header.Set("Content-Type", "application/json") patchRec := httptest.NewRecorder() router.ServeHTTP(patchRec, patchReq) if patchRec.Code != http.StatusUnauthorized { t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String()) } var errPayload apiErrorResponseTest if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil { t.Fatalf("decode: %v", err) } if errPayload.Error != "reauth_required" { t.Fatalf("expected reauth_required, got %q", errPayload.Error) } reauthReq := withSession( httptest.NewRequest( http.MethodPost, "/api/v1/auth/reauth", strings.NewReader(`{"password":"correct horse battery staple extra"}`), ), cookie, ) reauthReq.Header.Set("Content-Type", "application/json") reauthRec := httptest.NewRecorder() router.ServeHTTP(reauthRec, reauthReq) if reauthRec.Code != http.StatusOK { t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String()) } // Refresh cookie if rotated for _, setCookie := range reauthRec.Result().Cookies() { if setCookie.Name == auth.SessionCookieName { cookie = setCookie } } retryReq := withSession( httptest.NewRequest( http.MethodPatch, "/api/v1/users?id=test-user-id", strings.NewReader(`{"password":"another strong passphrase here"}`), ), cookie, ) retryReq.Header.Set("Content-Type", "application/json") retryRec := httptest.NewRecorder() router.ServeHTTP(retryRec, retryReq) if retryRec.Code != http.StatusOK { t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String()) } } func TestMePasswordChange(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) request := withSession( httptest.NewRequest( http.MethodPut, "/api/v1/me/password", strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`), ), cookie, ) request.Header.Set("Content-Type", "application/json") 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()) } // Session should be invalidated meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie) meRec := httptest.NewRecorder() router.ServeHTTP(meRec, meReq) if meRec.Code != http.StatusUnauthorized { t.Fatalf("expected session invalidated, got %d", meRec.Code) } }