787f822dc0
Сервер:
- миграция 00017: таблица instance_bans (причина, автор, дата);
- auth: ErrUserBanned, проверка бана в Login (код user.banned, 403) и в
ResolveSession — забаненный не получает сессию ни по cookie, ни по Bearer,
ни в Gateway, а прежняя сессия удаляется;
- store: BannedAt/BanReason в модели пользователя, BanInstanceUser,
UnbanInstanceUser, IsInstanceBanned, поиск и фильтр в ListUsers,
CountUsersFiltered; мягкое удаление аккаунта убирает и запись о бане;
- API: POST /instance/users/{id}/ban и /unban со step-up (AGENT.md 7.1),
отзыв сессий и SESSION_INVALIDATED, аудит instance.user_ban с причиной и
instance.user_unban; себя и инстанс-админа забанить нельзя;
- GET /instance/users: q (логин и отображаемое имя), banned=true, total.
Клиент:
- панель: поиск, фильтр «только забаненные», бейдж бана с причиной, кнопки
«Забанить» (с причиной) и «Разбанить» через общий шаг подтверждения
личности; i18n ru/en, включая текст ошибки user.banned;
- keepPreviousData в списке пользователей: без этого поле поиска
размонтировалось на первом же символе и набор обрывался.
Тесты: 4 Go-теста (бан блокирует вход и сессии, защита админов, поиск,
уборка бана при удалении), web-тест панели, живая проверка на стенде 19/19.
231 lines
9.6 KiB
Go
231 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|