feat(instance): админ-панель с лимитами медиа и действиями над пользователями
- лимиты медиа живут в настройках инстанса (миграция 00016): аватары, оформление сервера, эмодзи, звуки и галерея; все загрузки берут предел оттуда, значения по умолчанию — из AGENT.md 7.7 - действия администратора: временный пароль (показывается один раз, сессии отзываются), выход со всех устройств, мягкое удаление пользователя (сообщения и аудит остаются), переименование и удаление любого сервера - админ-панель разбита на вкладки: обзор и здоровье, лимиты, пользователи, серверы, аудит; смена администраторов подтверждается личностью (step-up) - тесты: Go (действия администратора, лимит эмодзи из настроек) и Vitest (вкладки, сброс пароля, выход, удаление, переименование сервера)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user