package server import ( "net/http" "testing" "glchat/internal/crypto" ) // instanceUsers вызывается администратором: список пользователей панели. func instanceUsers(t *testing.T, srv *Server, cookie *http.Cookie, query string) struct { Users []struct { ID string `json:"id"` Username string `json:"username"` Banned bool `json:"banned"` Reason string `json:"ban_reason"` } `json:"users"` Total int `json:"total"` } { t.Helper() path := "/api/v1/instance/users" if query != "" { path += "?" + query } rec := doJSON(t, srv, http.MethodGet, path, "", cookie) if rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, body = %s", path, rec.Code, rec.Body.String()) } return decodeResponse[struct { Users []struct { ID string `json:"id"` Username string `json:"username"` Banned bool `json:"banned"` Reason string `json:"ban_reason"` } `json:"users"` Total int `json:"total"` }](t, rec) } func TestInstanceBanBlocksLoginAndSessions(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "ban_admin", "ban-admin@example.com") promoteAdmin(t, srv, "ban-admin@example.com") userCookie := registerAndLogin(t, srv, "ban_target", "ban-target@example.com") target, err := srv.auth.UserByEmail(t.Context(), "ban-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID) // Бан без step-up отклоняется: действие чувствительное (AGENT.md 7.1). noStepUp := doJSON(t, srv, http.MethodPost, targetPath+"/ban", `{"reason":"спам"}`, adminCookie) if noStepUp.Code != http.StatusForbidden { t.Fatalf("ban without step-up = %d, want 403", noStepUp.Code) } if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { t.Fatalf("ban without step-up code = %q", code) } banned := doJSON(t, srv, http.MethodPost, targetPath+"/ban", `{"reason":"спам в общем канале","step_up_password":"correct-horse-battery"}`, adminCookie) if banned.Code != http.StatusOK { t.Fatalf("ban = %d, body = %s", banned.Code, banned.Body.String()) } // Список панели показывает бан и причину, фильтр «только забаненные» работает. list := instanceUsers(t, srv, adminCookie, "banned=true") if len(list.Users) != 1 || list.Users[0].Username != "ban_target" || !list.Users[0].Banned { t.Fatalf("banned filter = %+v", list.Users) } if list.Users[0].Reason != "спам в общем канале" { t.Fatalf("ban reason = %q", list.Users[0].Reason) } // Вход запрещён с отдельным кодом, а не «неверный пароль». login := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", `{"email":"ban-target@example.com","password":"correct-horse-battery"}`) if login.Code != http.StatusForbidden { t.Fatalf("login as banned = %d, want 403, body = %s", login.Code, login.Body.String()) } if code := errorCodeOf(t, login); code != "user.banned" { t.Fatalf("login as banned code = %q", code) } // Выданная ранее сессия перестаёт работать и отозвана в базе. me := doJSON(t, srv, http.MethodGet, "/api/v1/auth/sessions", "", userCookie) if me.Code != http.StatusUnauthorized && me.Code != http.StatusForbidden { t.Fatalf("banned session still works: %d, body = %s", me.Code, me.Body.String()) } if _, err := srv.store.GetSessionByTokenHash(t.Context(), crypto.HashToken(userCookie.Value)); err == nil { t.Fatal("session of banned user must be revoked") } // Разбан возвращает доступ: сессии не восстанавливаются, но вход снова работает. unban := doJSON(t, srv, http.MethodPost, targetPath+"/unban", `{"step_up_password":"correct-horse-battery"}`, adminCookie) if unban.Code != http.StatusOK { t.Fatalf("unban = %d, body = %s", unban.Code, unban.Body.String()) } loginAgain := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", `{"email":"ban-target@example.com","password":"correct-horse-battery"}`) if loginAgain.Code != http.StatusOK { t.Fatalf("login after unban = %d, body = %s", loginAgain.Code, loginAgain.Body.String()) } if list := instanceUsers(t, srv, adminCookie, ""); len(list.Users) != 2 || list.Total != 2 { t.Fatalf("users after unban = %+v (total %d)", list.Users, list.Total) } // Действия попали в аудит инстанса. audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie) entries := decodeResponse[struct { Entries []struct { Action string `json:"action"` Reason string `json:"reason"` ActorInstanceAdmin bool `json:"actor_instance_admin"` } `json:"entries"` }](t, audit) if !hasAction(entries.Entries, "instance.user_ban") || !hasAction(entries.Entries, "instance.user_unban") { t.Fatalf("audit has no ban entries: %+v", entries.Entries) } for _, entry := range entries.Entries { if entry.Action == "instance.user_ban" && entry.Reason != "спам в общем канале" { t.Fatalf("audit ban reason = %q", entry.Reason) } } } func TestInstanceBanProtectsAdmins(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "protect_admin", "protect-admin@example.com") promoteAdmin(t, srv, "protect-admin@example.com") registerAndLogin(t, srv, "protect_admin2", "protect-admin2@example.com") promoteAdmin(t, srv, "protect-admin2@example.com") admin, err := srv.auth.UserByEmail(t.Context(), "protect-admin@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } other, err := srv.auth.UserByEmail(t.Context(), "protect-admin2@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } // Инстанс-админа забанить нельзя (AGENT.md 7.19) — даже другому админу. rec := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban", `{"step_up_password":"correct-horse-battery"}`, adminCookie) if rec.Code != http.StatusForbidden { t.Fatalf("ban another admin = %d, want 403, body = %s", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "instance.admin_protected" { t.Fatalf("ban another admin code = %q", code) } // Себя банить тоже нельзя. self := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+formatSnowflake(admin.ID)+"/ban", `{"step_up_password":"correct-horse-battery"}`, adminCookie) if self.Code != http.StatusUnprocessableEntity { t.Fatalf("self ban = %d, want 422", self.Code) } // Обычный пользователь не может банить вообще. userCookie := registerAndLogin(t, srv, "protect_user", "protect-user@example.com") denied := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+formatSnowflake(other.ID)+"/ban", `{"step_up_password":"correct-horse-battery"}`, userCookie) if denied.Code != http.StatusForbidden { t.Fatalf("ban as user = %d, want 403", denied.Code) } if code := errorCodeOf(t, denied); code != "instance.admin_required" { t.Fatalf("ban as user code = %q", code) } } func TestInstanceUserSearch(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "search_admin", "search-admin@example.com") promoteAdmin(t, srv, "search-admin@example.com") registerAndLogin(t, srv, "search_alpha", "search-alpha@example.com") registerAndLogin(t, srv, "search_beta", "search-beta@example.com") found := instanceUsers(t, srv, adminCookie, "q=alpha") if len(found.Users) != 1 || found.Users[0].Username != "search_alpha" { t.Fatalf("search by username = %+v", found.Users) } if found.Total != 1 { t.Fatalf("search total = %d, want 1", found.Total) } // Поиск идёт и по отображаемому имени (оно равно логину при регистрации). byDisplay := instanceUsers(t, srv, adminCookie, "q=BETA") if len(byDisplay.Users) != 1 || byDisplay.Users[0].Username != "search_beta" { t.Fatalf("search is case-insensitive by display name = %+v", byDisplay.Users) } empty := instanceUsers(t, srv, adminCookie, "q=nobody-here") if len(empty.Users) != 0 || empty.Total != 0 { t.Fatalf("search with no matches = %+v (total %d)", empty.Users, empty.Total) } } func TestInstanceDeleteClearsBan(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "clear_admin", "clear-admin@example.com") promoteAdmin(t, srv, "clear-admin@example.com") registerAndLogin(t, srv, "clear_target", "clear-target@example.com") target, err := srv.auth.UserByEmail(t.Context(), "clear-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetPath := "/api/v1/instance/users/" + formatSnowflake(target.ID) if rec := doJSON(t, srv, http.MethodPost, targetPath+"/ban", `{"reason":"мусор","step_up_password":"correct-horse-battery"}`, adminCookie); rec.Code != http.StatusOK { t.Fatalf("ban = %d, body = %s", rec.Code, rec.Body.String()) } if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || !banned { t.Fatalf("IsInstanceBanned = %v, %v; want true", banned, err) } // Мягкое удаление убирает и запись о бане: аккаунта больше нет. if rec := doJSON(t, srv, http.MethodDelete, targetPath, "", adminCookie); rec.Code != http.StatusOK { t.Fatalf("delete = %d, body = %s", rec.Code, rec.Body.String()) } if banned, err := srv.store.IsInstanceBanned(t.Context(), target.ID); err != nil || banned { t.Fatalf("IsInstanceBanned after delete = %v, %v; want false", banned, err) } }