61cad0fdfc
Шапка комнаты показывает статус (описание) рядом с названием, но в снапшоте этого поля не было: клиент получал описание только REST-запросом списка комнат, поэтому статус появлялся с задержкой и не приходил в событиях комнат (AGENT.md 7.5, 11.6). - `ReadyChannel.Description` + заполнение в `channelPayload` — статус приходит и в READY, и в `CHANNEL_CREATE`/`CHANNEL_UPDATE` (те же payload'ы, что у REST); - Go-тест `TestReadyCarriesChannelStatus`: описание и медленный режим доезжают до снапшота. Клиентская часть (парсер и шапка) идёт следующим коммитом.
339 lines
14 KiB
Go
339 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
// realtimeClient — подключение к Gateway в тесте: используется, чтобы
|
|
// проверить, что изменения из REST-ручек доезжают без перезагрузки (§11.6).
|
|
type realtimeClient struct {
|
|
t *testing.T
|
|
conn *websocket.Conn
|
|
// ready — тело кадра READY: тесты снапшота читают из него списки серверов.
|
|
ready realtimeFrame
|
|
}
|
|
|
|
// dialGateway подключается к /gateway и проходит IDENTIFY пустым токеном:
|
|
// браузерный клиент аутентифицируется cookie (AGENT.md 8.1).
|
|
func dialGateway(t *testing.T, server *httptest.Server, cookie *http.Cookie) *realtimeClient {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/gateway"
|
|
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ //nolint:bodyclose // тело закрывает библиотека
|
|
HTTPHeader: map[string][]string{
|
|
"Origin": {"https://gl.mhspx.su"},
|
|
"Cookie": {cookie.Name + "=" + cookie.Value},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("dial gateway: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.CloseNow() })
|
|
|
|
client := &realtimeClient{t: t, conn: conn}
|
|
if hello := client.read(); hello.Op != 10 {
|
|
t.Fatalf("first frame op = %d, want HELLO", hello.Op)
|
|
}
|
|
client.send(map[string]any{"op": 2, "d": map[string]any{}})
|
|
ready := client.read()
|
|
if ready.T != "READY" {
|
|
t.Fatalf("frame = %q, want READY", ready.T)
|
|
}
|
|
client.ready = ready
|
|
return client
|
|
}
|
|
|
|
func (c *realtimeClient) send(payload any) {
|
|
c.t.Helper()
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
c.t.Fatalf("marshal frame: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := c.conn.Write(ctx, websocket.MessageText, body); err != nil {
|
|
c.t.Fatalf("write frame: %v", err)
|
|
}
|
|
}
|
|
|
|
type realtimeFrame struct {
|
|
Op int `json:"op"`
|
|
T string `json:"t"`
|
|
S int64 `json:"s"`
|
|
D json.RawMessage `json:"d"`
|
|
}
|
|
|
|
func (c *realtimeClient) read() realtimeFrame {
|
|
c.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_, data, err := c.conn.Read(ctx)
|
|
if err != nil {
|
|
c.t.Fatalf("read frame: %v", err)
|
|
}
|
|
var frame realtimeFrame
|
|
if err := json.Unmarshal(data, &frame); err != nil {
|
|
c.t.Fatalf("decode frame: %v (raw: %s)", err, data)
|
|
}
|
|
return frame
|
|
}
|
|
|
|
// expectEvent ждёт событие с указанным типом, пропуская чужие.
|
|
func (c *realtimeClient) expectEvent(event string) realtimeFrame {
|
|
c.t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
frame := c.read()
|
|
if frame.Op == 0 && frame.T == event {
|
|
return frame
|
|
}
|
|
}
|
|
c.t.Fatalf("event %s did not arrive", event)
|
|
return realtimeFrame{}
|
|
}
|
|
|
|
// TestRealtimeEventsReachConnectedClients проверяет §11.6 на уровне Gateway:
|
|
// изменения из REST приходят подключённым клиентам без перезагрузки.
|
|
func TestRealtimeEventsReachConnectedClients(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
httpServer := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(httpServer.Close)
|
|
|
|
ownerCookie := registerAndLogin(t, srv, "rt_owner", "rt-owner@example.com")
|
|
memberCookie := registerAndLogin(t, srv, "rt_member", "rt-member@example.com")
|
|
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Realtime"}`, 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)
|
|
|
|
owner := dialGateway(t, httpServer, ownerCookie)
|
|
member := dialGateway(t, httpServer, memberCookie)
|
|
|
|
// Создание комнаты: событие получают оба клиента.
|
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
|
`{"name":"новости","type":"text"}`, ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("create channel = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
owner.expectEvent("CHANNEL_CREATE")
|
|
member.expectEvent("CHANNEL_CREATE")
|
|
|
|
// Переименование сервера.
|
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID,
|
|
`{"name":"Realtime 2"}`, ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("update guild = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
member.expectEvent("GUILD_UPDATE")
|
|
|
|
// Роль и её выдача участнику.
|
|
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
|
|
`{"name":"Новая роль","permissions":"VIEW_CHANNEL"}`, ownerCookie)
|
|
role := decodeResponse[struct {
|
|
Role struct {
|
|
ID string `json:"id"`
|
|
} `json:"role"`
|
|
}](t, roleRec)
|
|
owner.expectEvent("ROLE_CREATE")
|
|
|
|
memberUser, err := srv.auth.UserByEmail(t.Context(), "rt-member@example.com")
|
|
if err != nil {
|
|
t.Fatalf("UserByEmail: %v", err)
|
|
}
|
|
if rec := doJSON(t, srv, http.MethodPut,
|
|
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(memberUser.ID)+"/roles/"+role.Role.ID,
|
|
"", ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("assign role = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
member.expectEvent("MEMBER_UPDATE")
|
|
|
|
// Профиль: событие приходит только владельцу сессии.
|
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
|
|
`{"display_name":"Участник"}`, memberCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("update profile = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
frame := member.expectEvent("USER_UPDATE")
|
|
var payload struct {
|
|
User struct {
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(frame.D, &payload); err != nil {
|
|
t.Fatalf("decode USER_UPDATE: %v", err)
|
|
}
|
|
if payload.User.DisplayName != "Участник" {
|
|
t.Fatalf("USER_UPDATE display name = %q", payload.User.DisplayName)
|
|
}
|
|
}
|
|
|
|
// expectNoEvent проверяет, что за отведённое время событие не пришло.
|
|
func (c *realtimeClient) expectNoEvent(event string, wait time.Duration) {
|
|
c.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
|
defer cancel()
|
|
for {
|
|
_, data, err := c.conn.Read(ctx)
|
|
if err != nil {
|
|
// Таймаут — то, что нужно: событие не доставлено.
|
|
return
|
|
}
|
|
var frame realtimeFrame
|
|
if err := json.Unmarshal(data, &frame); err != nil {
|
|
c.t.Fatalf("decode frame: %v", err)
|
|
}
|
|
if frame.Op == 0 && frame.T == event {
|
|
c.t.Fatalf("event %s must not reach this client", event)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChannelEventsFilteredByPermissions проверяет фильтрацию событий комнат:
|
|
// сообщения скрытой комнаты не уходят тем, кто её не видит (AGENT.md 8.3, 9.7).
|
|
func TestChannelEventsFilteredByPermissions(t *testing.T) {
|
|
f := newMessagingFixture(t)
|
|
httpServer := httptest.NewServer(f.srv.Handler())
|
|
t.Cleanup(httpServer.Close)
|
|
|
|
owner := dialGateway(t, httpServer, f.ownerCookie)
|
|
member := dialGateway(t, httpServer, f.memberCookie)
|
|
|
|
// Открытая комната: событие получают оба.
|
|
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
|
`{"content":"всем"}`, f.ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("open channel message = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
owner.expectEvent("MESSAGE_CREATE")
|
|
member.expectEvent("MESSAGE_CREATE")
|
|
|
|
// Скрытая комната: владелец получает событие, участник — нет.
|
|
if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.secretID+"/messages",
|
|
`{"content":"только для владельца"}`, f.ownerCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("secret channel message = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
owner.expectEvent("MESSAGE_CREATE")
|
|
member.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond)
|
|
}
|
|
|
|
// TestInstanceAdminManagesForeignGuild проверяет §11.5: администратор инстанса,
|
|
// не состоящий в сервере, видит и меняет всё, а его самого модерировать нельзя.
|
|
func TestInstanceAdminManagesForeignGuild(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
adminCookie := registerAndLogin(t, srv, "foreign_admin", "foreign-admin@example.com")
|
|
ownerCookie := registerAndLogin(t, srv, "foreign_owner", "foreign-owner@example.com")
|
|
memberCookie := registerAndLogin(t, srv, "foreign_member", "foreign-member@example.com")
|
|
promoteAdmin(t, srv, "foreign-admin@example.com")
|
|
|
|
// Первый сервер становится главным (его удалять нельзя), поэтому чужой
|
|
// сервер создаём вторым.
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
|
|
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)
|
|
|
|
// Админ не участник, но видит сервер, комнаты, роли и участников.
|
|
for _, path := range []string{"", "/channels", "/roles", "/members", "/audit-log"} {
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+path, "", adminCookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET %s as instance admin = %d, body = %s", path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// И список всех серверов инстанса тоже содержит чужой сервер.
|
|
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie)
|
|
list := decodeResponse[struct {
|
|
Guilds []struct {
|
|
ID string `json:"id"`
|
|
} `json:"guilds"`
|
|
}](t, guilds)
|
|
found := false
|
|
for _, item := range list.Guilds {
|
|
if item.ID == guild.Guild.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("instance guild list is missing the foreign guild: %+v", list.Guilds)
|
|
}
|
|
|
|
// Изменение чужого сервера и комнаты.
|
|
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID,
|
|
`{"name":"Переименован админом"}`, adminCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin rename guild = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
|
|
`{"name":"админ-комната","type":"text"}`, adminCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin create channel = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Кик участника и смена ника — без 403.
|
|
memberUser, err := srv.auth.UserByEmail(t.Context(), "foreign-member@example.com")
|
|
if err != nil {
|
|
t.Fatalf("UserByEmail: %v", err)
|
|
}
|
|
if rec := doJSON(t, srv, http.MethodPatch,
|
|
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(memberUser.ID),
|
|
`{"nickname":"переименован"}`, adminCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin set nickname = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := doJSON(t, srv, http.MethodDelete,
|
|
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(memberUser.ID),
|
|
"", adminCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin kick = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Действия администратора попадают в аудит сервера с флагом instance_admin.
|
|
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie)
|
|
entries := decodeResponse[struct {
|
|
Entries []struct {
|
|
Action string `json:"action"`
|
|
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
|
} `json:"entries"`
|
|
}](t, audit)
|
|
flagged := false
|
|
for _, entry := range entries.Entries {
|
|
if entry.Action == "member.kick" && entry.ActorInstanceAdmin {
|
|
flagged = true
|
|
}
|
|
}
|
|
if !flagged {
|
|
t.Fatalf("audit log has no admin member.kick: %+v", entries.Entries)
|
|
}
|
|
|
|
// Администратора инстанса нельзя исключить или замутить с сервера.
|
|
adminUser, err := srv.auth.UserByEmail(t.Context(), "foreign-admin@example.com")
|
|
if err != nil {
|
|
t.Fatalf("UserByEmail: %v", err)
|
|
}
|
|
// Владелец добавляет админа в сервер, чтобы проверка шла по участнику.
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", adminCookie)
|
|
protect := doJSON(t, srv, http.MethodDelete,
|
|
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(adminUser.ID), "", ownerCookie)
|
|
if protect.Code != http.StatusForbidden {
|
|
t.Fatalf("kick instance admin = %d, want 403", protect.Code)
|
|
}
|
|
if code := errorCodeOf(t, protect); code != "instance.admin_protected" {
|
|
t.Fatalf("error code = %q, want instance.admin_protected", code)
|
|
}
|
|
|
|
// Удаление чужого сервера администратором.
|
|
if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/guilds/"+guild.Guild.ID, "", adminCookie); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin delete guild = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|