feat(instance): админ-панель с лимитами медиа и действиями над пользователями

- лимиты медиа живут в настройках инстанса (миграция 00016): аватары,
  оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел
  оттуда, значения по умолчанию — из AGENT.md 7.7
- действия администратора: временный пароль (показывается один раз, сессии
  отзываются), выход со всех устройств, мягкое удаление пользователя
  (сообщения и аудит остаются), переименование и удаление любого сервера
- админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи,
  серверы, аудит; смена администраторов подтверждается личностью (step-up)
- тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest
  (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
2026-09-21 22:37:47 +03:00
parent dd4432452c
commit ef871bcd96
20 changed files with 1617 additions and 204 deletions
+141
View File
@@ -804,3 +804,144 @@ func TestStaticServesPWAAssets(t *testing.T) {
}
}
}
func TestInstanceAdminActions(t *testing.T) {
srv, admin, member, guildID, memberID := cosmeticsFixture(t)
// Первый зарегистрированный пользователь — администратор инстанса.
promoteAdmin(t, srv, "ban-owner@example.com")
// Переименование чужого сервера администратором.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+guildID,
`{"name":"Новое имя","description":"Описание"}`, admin)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование сервера = %d (%s)", renamed.Code, renamed.Body.String())
}
detail := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID, "", admin)
guild := decodeResponse[struct {
Guild struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"guild"`
}](t, detail).Guild
if guild.Name != "Новое имя" || guild.Description != "Описание" {
t.Fatalf("сервер не обновлён: %+v", guild)
}
// Сначала проверим, что обычный участник админ-ручки не трогает: дальше его
// сессия будет отозвана сбросом пароля.
if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", member); rec.Code != http.StatusForbidden {
t.Fatalf("удаление без прав = %d, ожидался 403 (%s)", rec.Code, rec.Body.String())
}
// Сброс пароля участнику: пароль выдаётся один раз, сессии отзываются.
before := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`)
if before.Code != http.StatusOK {
t.Fatalf("вход до сброса = %d (%s)", before.Code, before.Body.String())
}
reset := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+memberID+"/reset-password", "", admin)
if reset.Code != http.StatusOK {
t.Fatalf("сброс пароля = %d (%s)", reset.Code, reset.Body.String())
}
password := decodeResponse[struct {
Password string `json:"password"`
SessionsRevoked int64 `json:"sessions_revoked"`
}](t, reset)
if len(password.Password) < 12 {
t.Fatalf("временный пароль слишком короткий: %q", password.Password)
}
if password.SessionsRevoked < 1 {
t.Fatalf("сессии не отозваны: %d", password.SessionsRevoked)
}
// Старый пароль больше не работает, новый — работает.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`); rec.Code == http.StatusOK {
t.Fatal("старый пароль продолжает работать")
}
relogin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"`+password.Password+`"}`)
if relogin.Code != http.StatusOK {
t.Fatalf("вход с новым паролем = %d (%s)", relogin.Code, relogin.Body.String())
}
// Выход со всех устройств.
logout := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+memberID+"/logout", "", admin)
if logout.Code != http.StatusOK {
t.Fatalf("выход со всех устройств = %d (%s)", logout.Code, logout.Body.String())
}
if rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member); rec.Code != http.StatusUnauthorized {
t.Fatalf("сессия участника жива: %d", rec.Code)
}
// Администратор удаляет пользователя: мягкое удаление (AGENT.md 6.4).
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", admin)
if deleted.Code != http.StatusOK {
t.Fatalf("удаление пользователя = %d (%s)", deleted.Code, deleted.Body.String())
}
users := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users", "", admin)
list := decodeResponse[struct {
Users []struct {
UserID string `json:"id"`
} `json:"users"`
}](t, users)
for _, user := range list.Users {
if user.UserID == memberID {
t.Fatal("удалённый пользователь остался в списке")
}
}
}
func TestInstanceMediaLimitsEnforced(t *testing.T) {
srv, admin, _, guildID, _ := cosmeticsFixture(t)
promoteAdmin(t, srv, "ban-owner@example.com")
// Ограничиваем эмодзи 64 КБ и пробуем загрузить картинку больше лимита.
patched := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_emoji_size":65536}`, admin)
if patched.Code != http.StatusOK {
t.Fatalf("настройки = %d (%s)", patched.Code, patched.Body.String())
}
settings := doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "", admin)
payload := decodeResponse[struct {
Settings struct {
MaxEmojiSize int64 `json:"max_emoji_size"`
} `json:"settings"`
}](t, settings)
if payload.Settings.MaxEmojiSize != 65536 {
t.Fatalf("лимит эмодзи = %d", payload.Settings.MaxEmojiSize)
}
big := make([]byte, 70<<10)
copy(big, pngBytes())
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if err := writer.WriteField("name", "big_emoji"); err != nil {
t.Fatalf("multipart name: %v", err)
}
part, err := writer.CreateFormFile("file", "big.png")
if err != nil {
t.Fatalf("multipart file: %v", err)
}
if _, err := part.Write(big); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/guilds/"+guildID+"/emojis", &buf)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(admin)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
t.Fatalf("загрузка сверх лимита = %d, ожидался отказ (%s)", rec.Code, rec.Body.String())
}
// Возвращаем лимит по умолчанию.
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_emoji_size":524288}`, admin); rec.Code != http.StatusOK {
t.Fatalf("возврат лимита = %d", rec.Code)
}
}