feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

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).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+631
View File
@@ -0,0 +1,631 @@
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
}