Files
glchat/internal/server/realtime_test.go
T

339 lines
14 KiB
Go
Raw Normal View History

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())
}
}