Files
glchat/internal/server/api_test.go
T
grendervill b189b3592b feat(api): список событий безопасности аккаунта
GET /users/@me/security-events отдаёт последние входы, неудачные попытки,
смены пароля и изменения 2FA (AGENT.md 7.1: уведомления о новых входах —
основа для настроек безопасности в клиенте).
2026-09-19 21:55:52 +03:00

672 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 TestSecurityEventsListed(t *testing.T) {
srv, _ := newTestServer(t)
cookie := registerAndLogin(t, srv, "events_user", "events@example.com")
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/security-events", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("security events = %d, body = %s", rec.Code, rec.Body.String())
}
payload := decodeResponse[struct {
Events []struct {
Type string `json:"type"`
} `json:"events"`
}](t, rec)
if len(payload.Events) == 0 {
t.Fatal("registration must leave a security event")
}
if payload.Events[0].Type != "register" {
t.Fatalf("first event = %q, want register", payload.Events[0].Type)
}
}
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
}
func TestAuthRateLimitReturns429(t *testing.T) {
srv, _ := newTestServer(t)
var last *httptest.ResponseRecorder
// Лимит входа — 5 запросов в минуту на IP (AGENT.md 8.6).
for range 6 {
last = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"nobody@example.com","password":"wrong-password"}`)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("sixth login attempt = %d, want 429", last.Code)
}
if code := errorCodeOf(t, last); code != "rate_limited" {
t.Fatalf("error code = %q, want rate_limited", code)
}
if last.Header().Get("Retry-After") == "" {
t.Error("Retry-After header is missing")
}
}