feat(guilds): приватные комнаты — права доступа поверх серверных
Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
(права именами через `|`, как у ролей), step-up на изменение, проверка что
роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.
Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.
Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user