package server import ( "net/http" "testing" ) // channelIDs забирает видимые комнаты сервера глазами конкретной сессии. func channelIDs(t *testing.T, srv *Server, cookie *http.Cookie, guildID string) []string { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("GET channels = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Channels []struct { ID string `json:"id"` } `json:"channels"` }](t, rec) ids := make([]string, 0, len(payload.Channels)) for _, channel := range payload.Channels { ids = append(ids, channel.ID) } return ids } func containsID(ids []string, want string) bool { for _, id := range ids { if id == want { return true } } return false } func defaultRoleID(t *testing.T, srv *Server, cookie *http.Cookie, guildID string) string { t.Helper() rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/roles", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("GET roles = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Roles []struct { ID string `json:"id"` IsDefault bool `json:"is_default"` } `json:"roles"` }](t, rec) for _, role := range payload.Roles { if role.IsDefault { return role.ID } } t.Fatal("default role not found") return "" } // TestChannelOverwritesHideAndRevealChannel проверяет приватную комнату // (AGENT.md 6.2, 11.6): запрет @everyone скрывает комнату у участника, // разрешение роли — открывает, снятие оверрайдов возвращает исходное состояние. func TestChannelOverwritesHideAndRevealChannel(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "overwrite_owner", "overwrite-owner@example.com") memberCookie := registerAndLogin(t, srv, "overwrite_member", "overwrite-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) guildID := guild.Guild.ID channelRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels", `{"name":"тайная","type":"text"}`, ownerCookie) channel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, channelRec) channelID := channel.Channel.ID roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles", `{"name":"Доверенные"}`, ownerCookie) role := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, roleRec) roleID := role.Role.ID inviteRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites", `{"max_uses":0,"max_age_seconds":3600}`, ownerCookie) invite := decodeResponse[struct { Invite struct { Code string `json:"code"` } `json:"invite"` }](t, inviteRec) if rec := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+invite.Invite.Code, "", memberCookie); rec.Code != http.StatusOK { t.Fatalf("accept invite = %d, body = %s", rec.Code, rec.Body.String()) } member, err := srv.auth.UserByEmail(t.Context(), "overwrite-member@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } memberPathID := formatSnowflake(member.ID) // Пока оверрайдов нет, комната видна всем участникам сервера. if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) { t.Fatalf("member must see the channel before overwrites: %v", ids) } const viewMask = "VIEW_CHANNEL" everyoneID := defaultRoleID(t, srv, ownerCookie, guildID) basePath := "/api/v1/guilds/" + guildID + "/channels/" + channelID + "/overwrites/" // Участник не управляет правами комнаты. denied := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID, `{"deny":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, memberCookie) if denied.Code != http.StatusForbidden { t.Fatalf("member overwrite = %d, want 403", denied.Code) } // Без подтверждения личности права не меняются. noStepUp := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID, `{"deny":"`+viewMask+`"}`, ownerCookie) if noStepUp.Code != http.StatusForbidden { t.Fatalf("overwrite without step-up = %d, want 403, body = %s", noStepUp.Code, noStepUp.Body.String()) } if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { t.Fatalf("overwrite without step-up code = %q", code) } // Закрываем комнату для @everyone: участник её больше не видит. closed := doJSON(t, srv, http.MethodPut, basePath+"role/"+everyoneID, `{"deny":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, ownerCookie) if closed.Code != http.StatusOK { t.Fatalf("close channel = %d, body = %s", closed.Code, closed.Body.String()) } closedPayload := decodeResponse[struct { Channel struct { PermissionOverwrites []struct { TargetType string `json:"target_type"` TargetID string `json:"target_id"` Deny string `json:"deny"` } `json:"permission_overwrites"` } `json:"channel"` }](t, closed) if len(closedPayload.Channel.PermissionOverwrites) != 1 || closedPayload.Channel.PermissionOverwrites[0].Deny != viewMask { t.Fatalf("overwrites in payload = %+v", closedPayload.Channel.PermissionOverwrites) } if ids := channelIDs(t, srv, memberCookie, guildID); containsID(ids, channelID) { t.Fatalf("private channel must be hidden from member: %v", ids) } // Владелец по-прежнему видит комнату (у него ADMINISTRATOR). if ids := channelIDs(t, srv, ownerCookie, guildID); !containsID(ids, channelID) { t.Fatalf("owner must still see the channel: %v", ids) } // Разрешение роли возвращает доступ: выдаём роль участнику и открываем комнату. if rec := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/members/"+memberPathID+"/roles/"+roleID, "", ownerCookie); rec.Code != http.StatusOK { t.Fatalf("assign role = %d, body = %s", rec.Code, rec.Body.String()) } allow := doJSON(t, srv, http.MethodPut, basePath+"role/"+roleID, `{"allow":"`+viewMask+`","step_up_password":"correct-horse-battery"}`, ownerCookie) if allow.Code != http.StatusOK { t.Fatalf("allow role = %d, body = %s", allow.Code, allow.Body.String()) } if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) { t.Fatalf("role allow must open the channel: %v", ids) } // Снятие оверрайда с роли снова закрывает комнату (запрет @everyone остался). removed := doJSON(t, srv, http.MethodDelete, basePath+"role/"+roleID, "", ownerCookie) if removed.Code != http.StatusOK { t.Fatalf("delete role overwrite = %d, body = %s", removed.Code, removed.Body.String()) } if ids := channelIDs(t, srv, memberCookie, guildID); containsID(ids, channelID) { t.Fatalf("channel must be hidden again after removing the role overwrite: %v", ids) } // Снятие запрета @everyone делает комнату обычной. if rec := doJSON(t, srv, http.MethodDelete, basePath+"role/"+everyoneID, "", ownerCookie); rec.Code != http.StatusOK { t.Fatalf("delete everyone overwrite = %d, body = %s", rec.Code, rec.Body.String()) } if ids := channelIDs(t, srv, memberCookie, guildID); !containsID(ids, channelID) { t.Fatalf("channel must be visible after clearing overwrites: %v", ids) } // Аудит: изменения прав комнаты записаны. audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log", "", ownerCookie) entries := decodeResponse[struct { Entries []struct { Action string `json:"action"` TargetType string `json:"target_type"` } `json:"entries"` }](t, audit) if !hasAction(entries.Entries, "channel.overwrite_set") || !hasAction(entries.Entries, "channel.overwrite_delete") { t.Fatalf("audit has no overwrite entries: %+v", entries.Entries) } } // TestChannelOverwriteTargetValidation проверяет отказ на чужую цель. func TestChannelOverwriteTargetValidation(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "target_owner", "target-owner@example.com") registerAndLogin(t, srv, "target_outsider", "target-outsider@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) channelRec := 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, channelRec) outsider, err := srv.auth.UserByEmail(t.Context(), "target-outsider@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } base := "/api/v1/guilds/" + guild.Guild.ID + "/channels/" + channel.Channel.ID + "/overwrites/" // Пользователь не состоит в сервере. notMember := doJSON(t, srv, http.MethodPut, base+"user/"+formatSnowflake(outsider.ID), `{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie) if notMember.Code != http.StatusNotFound { t.Fatalf("overwrite for non-member = %d, want 404, body = %s", notMember.Code, notMember.Body.String()) } // Роль из другого сервера. otherGuild := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Другой"}`, ownerCookie) other := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, otherGuild) otherRole := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+other.Guild.ID+"/roles", `{"name":"Чужая"}`, ownerCookie) role := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, otherRole) foreign := doJSON(t, srv, http.MethodPut, base+"role/"+role.Role.ID, `{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie) if foreign.Code != http.StatusNotFound { t.Fatalf("overwrite for foreign role = %d, want 404, body = %s", foreign.Code, foreign.Body.String()) } // Неизвестный тип цели — 422 (huma отклоняет значение вне enum). badType := doJSON(t, srv, http.MethodPut, base+"everyone/123", `{"allow":"VIEW_CHANNEL","step_up_password":"correct-horse-battery"}`, ownerCookie) if badType.Code != http.StatusUnprocessableEntity && badType.Code != http.StatusBadRequest { t.Fatalf("overwrite with bad target type = %d, want 422, body = %s", badType.Code, badType.Body.String()) } }