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