diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 604fada..cfc5685 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -907,6 +907,11 @@ func (s *Server) checkMemberHierarchy(ctx context.Context, guildID uint64, actor if err != nil { return humaError(err) } + // Администратора инстанса нельзя модерировать с сервера: его права + // глобальны и не могут быть отозваны владельцем сервера (AGENT.md 7.19). + if target, err := s.store.GetUser(ctx, targetID); err == nil && target.IsInstanceAdmin { + return humaErrorStatus(http.StatusForbidden, "instance.admin_protected", "instance administrator cannot be moderated") + } if targetID == guild.OwnerID && actor.ID != guild.OwnerID { return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated") } diff --git a/internal/server/realtime_test.go b/internal/server/realtime_test.go new file mode 100644 index 0000000..5c50555 --- /dev/null +++ b/internal/server/realtime_test.go @@ -0,0 +1,287 @@ +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("GUILD_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_ROLES_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) + } +} + +// 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()) + } +}