1b1a679827
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):
- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
не участник не получает прав роли @user; калькулятор прав общий для API и
Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.
Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
632 lines
24 KiB
Go
632 lines
24 KiB
Go
package server
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"glchat/internal/permissions"
|
||
"glchat/internal/store"
|
||
)
|
||
|
||
// permissionViewChannel — сокращение для оверрайдов в тестах.
|
||
const permissionViewChannel = permissions.ViewChannel
|
||
|
||
// registerAndLogin регистрирует пользователя и возвращает cookie сессии.
|
||
func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie {
|
||
t.Helper()
|
||
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||
`{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String())
|
||
}
|
||
cookies := rec.Result().Cookies()
|
||
if len(cookies) == 0 {
|
||
t.Fatalf("register %s did not return a session cookie", username)
|
||
}
|
||
return cookies[0]
|
||
}
|
||
|
||
func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
|
||
t.Helper()
|
||
var payload T
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||
t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String())
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||
t.Helper()
|
||
payload := decodeResponse[struct {
|
||
Error struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}](t, rec)
|
||
if payload.Error.Code == "" {
|
||
t.Fatalf("response has no error code: %s", rec.Body.String())
|
||
}
|
||
return payload.Error.Code
|
||
}
|
||
|
||
// promoteAdmin делает пользователя администратором инстанса напрямую в БД.
|
||
func promoteAdmin(t *testing.T, srv *Server, email string) {
|
||
t.Helper()
|
||
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||
if err != nil {
|
||
t.Fatalf("find user %s: %v", email, err)
|
||
}
|
||
if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil {
|
||
t.Fatalf("promote %s: %v", email, err)
|
||
}
|
||
}
|
||
|
||
func TestHumaErrorsUseAPIEnvelope(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
|
||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "")
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401", rec.Code)
|
||
}
|
||
if code := errorCodeOf(t, rec); code != "auth.session_expired" {
|
||
t.Fatalf("error code = %q, want auth.session_expired", code)
|
||
}
|
||
|
||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "")
|
||
if rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("instance settings without session = %d, want 401", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestProfileUpdateFlow(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com")
|
||
|
||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
|
||
if me.Code != http.StatusOK {
|
||
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
|
||
}
|
||
profile := decodeResponse[struct {
|
||
User struct {
|
||
Username string `json:"username"`
|
||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||
Locale string `json:"locale"`
|
||
} `json:"user"`
|
||
}](t, me)
|
||
if profile.User.Username != "profile_user" {
|
||
t.Fatalf("username = %q", profile.User.Username)
|
||
}
|
||
if profile.User.OnboardingCompleted {
|
||
t.Fatal("new user must not have onboarding completed")
|
||
}
|
||
if profile.User.Locale != "ru" {
|
||
t.Fatalf("default locale = %q, want ru", profile.User.Locale)
|
||
}
|
||
|
||
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
|
||
`{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie)
|
||
if updated.Code != http.StatusOK {
|
||
t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String())
|
||
}
|
||
payload := decodeResponse[struct {
|
||
User struct {
|
||
DisplayName string `json:"display_name"`
|
||
Bio string `json:"bio"`
|
||
Status string `json:"status"`
|
||
Locale string `json:"locale"`
|
||
} `json:"user"`
|
||
}](t, updated)
|
||
if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" {
|
||
t.Fatalf("unexpected profile: %+v", payload.User)
|
||
}
|
||
|
||
// Онбординг завершается отдельной ручкой и выставляет флаг.
|
||
onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete",
|
||
`{"display_name":"Новый ник","bio":"привет"}`, cookie)
|
||
if onboarding.Code != http.StatusOK {
|
||
t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String())
|
||
}
|
||
after := decodeResponse[struct {
|
||
User struct {
|
||
DisplayName string `json:"display_name"`
|
||
OnboardingCompleted bool `json:"onboarding_completed"`
|
||
} `json:"user"`
|
||
}](t, onboarding)
|
||
if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" {
|
||
t.Fatalf("unexpected onboarding result: %+v", after.User)
|
||
}
|
||
|
||
// Публичный профиль доступен другому пользователю и не содержит приватных полей.
|
||
otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com")
|
||
user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie)
|
||
if public.Code != http.StatusOK {
|
||
t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String())
|
||
}
|
||
if strings.Contains(public.Body.String(), "@example.com") {
|
||
t.Fatalf("public profile must not leak email: %s", public.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestGuildLifecycleAndPermissions(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com")
|
||
memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com")
|
||
|
||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie)
|
||
if created.Code != http.StatusOK {
|
||
t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String())
|
||
}
|
||
guild := decodeResponse[struct {
|
||
Guild struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
IsMain bool `json:"is_main"`
|
||
Roles []struct {
|
||
ID string `json:"id"`
|
||
IsDefault bool `json:"is_default"`
|
||
} `json:"roles"`
|
||
Channels []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
} `json:"channels"`
|
||
MyPermissions []string `json:"my_permissions"`
|
||
} `json:"guild"`
|
||
}](t, created)
|
||
if guild.Guild.Name != "Тестовый сервер" {
|
||
t.Fatalf("guild name = %q", guild.Guild.Name)
|
||
}
|
||
if len(guild.Guild.Roles) != 2 {
|
||
t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles))
|
||
}
|
||
if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" {
|
||
t.Fatalf("default channels = %+v", guild.Guild.Channels)
|
||
}
|
||
if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") {
|
||
t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions)
|
||
}
|
||
|
||
// Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7).
|
||
stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||
if stranger.Code != http.StatusNotFound {
|
||
t.Fatalf("stranger channels = %d, want 404", stranger.Code)
|
||
}
|
||
|
||
// Главный сервер открыт для присоединения.
|
||
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||
if join.Code != http.StatusOK {
|
||
t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String())
|
||
}
|
||
|
||
// Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS.
|
||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||
if channels.Code != http.StatusOK {
|
||
t.Fatalf("member channels = %d", channels.Code)
|
||
}
|
||
list := decodeResponse[struct {
|
||
Channels []struct {
|
||
Name string `json:"name"`
|
||
CanView bool `json:"can_view"`
|
||
CanSend bool `json:"can_send"`
|
||
} `json:"channels"`
|
||
}](t, channels)
|
||
if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend {
|
||
t.Fatalf("unexpected member channels: %+v", list.Channels)
|
||
}
|
||
denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||
`{"name":"секретная","type":"text"}`, memberCookie)
|
||
if denied.Code != http.StatusForbidden {
|
||
t.Fatalf("member create channel = %d, want 403", denied.Code)
|
||
}
|
||
if code := errorCodeOf(t, denied); code != "perm.denied" {
|
||
t.Fatalf("error code = %q, want perm.denied", code)
|
||
}
|
||
|
||
// Владелец создаёт комнату и роль.
|
||
newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||
`{"name":"флудилка","type":"voice"}`, ownerCookie)
|
||
if newChannel.Code != http.StatusOK {
|
||
t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String())
|
||
}
|
||
|
||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie)
|
||
if roleRec.Code != http.StatusOK {
|
||
t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String())
|
||
}
|
||
role := decodeResponse[struct {
|
||
Role struct {
|
||
ID string `json:"id"`
|
||
Permissions string `json:"permissions"`
|
||
} `json:"role"`
|
||
}](t, roleRec)
|
||
if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") {
|
||
t.Fatalf("role permissions = %q", role.Role.Permissions)
|
||
}
|
||
|
||
// Роль можно выдать участнику.
|
||
member, err := srv.auth.UserByEmail(t.Context(), "member@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
assign := doJSON(t, srv, http.MethodPut,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||
if assign.Code != http.StatusOK {
|
||
t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String())
|
||
}
|
||
members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie)
|
||
roster := decodeResponse[struct {
|
||
Members []struct {
|
||
UserID string `json:"user_id"`
|
||
RoleIDs []string `json:"role_ids"`
|
||
} `json:"members"`
|
||
}](t, members)
|
||
if len(roster.Members) != 2 {
|
||
t.Fatalf("members = %d, want 2", len(roster.Members))
|
||
}
|
||
found := false
|
||
for _, entry := range roster.Members {
|
||
if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("role was not assigned: %+v", roster.Members)
|
||
}
|
||
|
||
// Участник не может исключить владельца даже с правом KICK_MEMBERS.
|
||
owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
kickOwner := doJSON(t, srv, http.MethodDelete,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie)
|
||
if kickOwner.Code != http.StatusForbidden {
|
||
t.Fatalf("kick owner = %d, want 403", kickOwner.Code)
|
||
}
|
||
|
||
// Владелец исключает участника.
|
||
kick := doJSON(t, srv, http.MethodDelete,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie)
|
||
if kick.Code != http.StatusOK {
|
||
t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String())
|
||
}
|
||
|
||
// Журнал аудита содержит действие исключения.
|
||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie)
|
||
entries := decodeResponse[struct {
|
||
Entries []struct {
|
||
Action string `json:"action"`
|
||
} `json:"entries"`
|
||
}](t, audit)
|
||
if !hasAction(entries.Entries, "member.kick") {
|
||
t.Fatalf("audit log has no member.kick: %+v", entries.Entries)
|
||
}
|
||
|
||
// Главный сервер удалить нельзя.
|
||
deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie)
|
||
if deleteMain.Code != http.StatusForbidden {
|
||
t.Fatalf("delete main guild = %d, want 403", deleteMain.Code)
|
||
}
|
||
}
|
||
|
||
func TestInstanceAdminEndpointsAndLimits(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com")
|
||
promoteAdmin(t, srv, "admin@example.com")
|
||
userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com")
|
||
|
||
// Публичная информация об инстансе доступна без сессии.
|
||
public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "")
|
||
if public.Code != http.StatusOK {
|
||
t.Fatalf("GET /instance = %d", public.Code)
|
||
}
|
||
publicPayload := decodeResponse[struct {
|
||
Instance struct {
|
||
RegistrationEnabled bool `json:"registration_enabled"`
|
||
UserCount int `json:"user_count"`
|
||
} `json:"instance"`
|
||
}](t, public)
|
||
if !publicPayload.Instance.RegistrationEnabled {
|
||
t.Fatal("registration must be enabled by default")
|
||
}
|
||
if publicPayload.Instance.UserCount != 0 {
|
||
t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount)
|
||
}
|
||
|
||
// Обычный пользователь не имеет доступа к админ-ручкам.
|
||
for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} {
|
||
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
|
||
if rec.Code != http.StatusForbidden {
|
||
t.Fatalf("GET %s as user = %d, want 403", path, rec.Code)
|
||
}
|
||
if code := errorCodeOf(t, rec); code != "instance.admin_required" {
|
||
t.Fatalf("GET %s code = %q", path, code)
|
||
}
|
||
}
|
||
|
||
// Лимит в один сервер на пользователя.
|
||
patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
|
||
`{"max_guilds_per_user":1}`, adminCookie)
|
||
if patch.Code != http.StatusOK {
|
||
t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String())
|
||
}
|
||
|
||
first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie)
|
||
if first.Code != http.StatusOK {
|
||
t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String())
|
||
}
|
||
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie)
|
||
if second.Code != http.StatusForbidden {
|
||
t.Fatalf("second guild = %d, want 403", second.Code)
|
||
}
|
||
if code := errorCodeOf(t, second); code != "limits.guilds_reached" {
|
||
t.Fatalf("limit error code = %q, want limits.guilds_reached", code)
|
||
}
|
||
|
||
// Администратор инстанса обходит лимит, и это попадает в аудит.
|
||
adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie)
|
||
if adminGuild.Code != http.StatusOK {
|
||
t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String())
|
||
}
|
||
|
||
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie)
|
||
guildList := decodeResponse[struct {
|
||
Guilds []struct {
|
||
Name string `json:"name"`
|
||
} `json:"guilds"`
|
||
}](t, guilds)
|
||
if len(guildList.Guilds) != 2 {
|
||
t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds))
|
||
}
|
||
|
||
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
|
||
entries := decodeResponse[struct {
|
||
Entries []struct {
|
||
Action string `json:"action"`
|
||
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||
} `json:"entries"`
|
||
}](t, audit)
|
||
if !hasAction(entries.Entries, "limits.bypass") {
|
||
t.Fatalf("audit has no limits.bypass: %+v", entries.Entries)
|
||
}
|
||
for _, entry := range entries.Entries {
|
||
if !entry.ActorInstanceAdmin {
|
||
t.Fatalf("instance audit entry without admin flag: %+v", entry)
|
||
}
|
||
}
|
||
|
||
// Права администратора выдаются только со step-up.
|
||
user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
noStepUp := doJSON(t, srv, http.MethodPost,
|
||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie)
|
||
if noStepUp.Code != http.StatusForbidden {
|
||
t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code)
|
||
}
|
||
grant := doJSON(t, srv, http.MethodPost,
|
||
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin",
|
||
`{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie)
|
||
if grant.Code != http.StatusOK {
|
||
t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String())
|
||
}
|
||
|
||
// Смена настроек регистрации закрывает регистрацию.
|
||
off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie)
|
||
if off.Code != http.StatusOK {
|
||
t.Fatalf("disable registration = %d", off.Code)
|
||
}
|
||
blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
|
||
`{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`)
|
||
if blocked.Code == http.StatusOK {
|
||
t.Fatal("registration must be rejected when disabled")
|
||
}
|
||
}
|
||
|
||
func TestMemberTimeoutRequiresPermission(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com")
|
||
memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com")
|
||
|
||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie)
|
||
guild := decodeResponse[struct {
|
||
Guild struct {
|
||
ID string `json:"id"`
|
||
} `json:"guild"`
|
||
}](t, created)
|
||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||
|
||
member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
// У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён.
|
||
denied := doJSON(t, srv, http.MethodPatch,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie)
|
||
if denied.Code != http.StatusForbidden {
|
||
t.Fatalf("member self timeout = %d, want 403", denied.Code)
|
||
}
|
||
|
||
// Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются.
|
||
ok := doJSON(t, srv, http.MethodPatch,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
|
||
`{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie)
|
||
if ok.Code != http.StatusOK {
|
||
t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String())
|
||
}
|
||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||
list := decodeResponse[struct {
|
||
Channels []struct {
|
||
CanSend bool `json:"can_send"`
|
||
} `json:"channels"`
|
||
}](t, channels)
|
||
if len(list.Channels) == 0 || list.Channels[0].CanSend {
|
||
t.Fatalf("timed out member must not be able to send: %+v", list.Channels)
|
||
}
|
||
}
|
||
|
||
func TestRoleHierarchyProtectsHigherRoles(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com")
|
||
modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com")
|
||
|
||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie)
|
||
guild := decodeResponse[struct {
|
||
Guild struct {
|
||
ID string `json:"id"`
|
||
} `json:"guild"`
|
||
}](t, created)
|
||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie)
|
||
|
||
mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com")
|
||
if err != nil {
|
||
t.Fatalf("UserByEmail: %v", err)
|
||
}
|
||
// Роль модератора с правом управления ролями.
|
||
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
||
`{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie)
|
||
role := decodeResponse[struct {
|
||
Role struct {
|
||
ID string `json:"id"`
|
||
} `json:"role"`
|
||
}](t, roleRec)
|
||
assign := doJSON(t, srv, http.MethodPut,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
|
||
if assign.Code != http.StatusOK {
|
||
t.Fatalf("assign moderator role = %d", assign.Code)
|
||
}
|
||
|
||
// Модератор пытается снять собственную роль: себе модерировать нельзя.
|
||
self := doJSON(t, srv, http.MethodDelete,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie)
|
||
if self.Code != http.StatusForbidden {
|
||
t.Fatalf("self moderation = %d, want 403", self.Code)
|
||
}
|
||
|
||
// Модератор пытается выдать себе роль администратора: она выше его роли.
|
||
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie)
|
||
roleList := decodeResponse[struct {
|
||
Roles []struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
} `json:"roles"`
|
||
}](t, roles)
|
||
var adminRoleID string
|
||
for _, candidate := range roleList.Roles {
|
||
if candidate.Name == "Администратор" {
|
||
adminRoleID = candidate.ID
|
||
}
|
||
}
|
||
if adminRoleID == "" {
|
||
t.Fatal("administrator role is missing")
|
||
}
|
||
escalate := doJSON(t, srv, http.MethodPut,
|
||
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie)
|
||
if escalate.Code != http.StatusForbidden {
|
||
t.Fatalf("privilege escalation = %d, want 403", escalate.Code)
|
||
}
|
||
}
|
||
|
||
func TestChannelOverridesHideChannel(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com")
|
||
memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com")
|
||
|
||
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie)
|
||
guild := decodeResponse[struct {
|
||
Guild struct {
|
||
ID string `json:"id"`
|
||
Roles []struct {
|
||
ID string `json:"id"`
|
||
IsDefault bool `json:"is_default"`
|
||
} `json:"roles"`
|
||
} `json:"guild"`
|
||
}](t, created)
|
||
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
|
||
|
||
secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
||
`{"name":"тайная","type":"text"}`, ownerCookie)
|
||
channel := decodeResponse[struct {
|
||
Channel struct {
|
||
ID string `json:"id"`
|
||
} `json:"channel"`
|
||
}](t, secret)
|
||
|
||
// Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён.
|
||
channelID, err := parseID("channel_id", channel.Channel.ID)
|
||
if err != nil {
|
||
t.Fatalf("parse channel id: %v", err)
|
||
}
|
||
var defaultRoleID uint64
|
||
for _, role := range guild.Guild.Roles {
|
||
if role.IsDefault {
|
||
defaultRoleID, err = parseID("role_id", role.ID)
|
||
if err != nil {
|
||
t.Fatalf("parse role id: %v", err)
|
||
}
|
||
}
|
||
}
|
||
if defaultRoleID == 0 {
|
||
t.Fatal("default role not found")
|
||
}
|
||
if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{
|
||
ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID,
|
||
Deny: uint64(permissionViewChannel),
|
||
}); err != nil {
|
||
t.Fatalf("SetChannelOverride: %v", err)
|
||
}
|
||
srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID))
|
||
|
||
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
|
||
list := decodeResponse[struct {
|
||
Channels []struct {
|
||
Name string `json:"name"`
|
||
} `json:"channels"`
|
||
}](t, channels)
|
||
for _, entry := range list.Channels {
|
||
if entry.Name == "тайная" {
|
||
t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels)
|
||
}
|
||
}
|
||
}
|
||
|
||
func guildIDOf(t *testing.T, raw string) uint64 {
|
||
t.Helper()
|
||
id, err := parseID("guild_id", raw)
|
||
if err != nil {
|
||
t.Fatalf("parse guild id: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
func containsString(values []string, wanted string) bool {
|
||
for _, value := range values {
|
||
if value == wanted {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasAction[T any](entries []T, action string) bool {
|
||
for _, entry := range entries {
|
||
encoded, err := json.Marshal(entry)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if strings.Contains(string(encoded), `"`+action+`"`) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|