2026-09-19 21:50:06 +03:00
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:55:52 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-09-19 21:51:47 +03:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|