From 5176b277a63027a45c31e126c8255202871a3b97 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 11:06:42 +0300 Subject: [PATCH] =?UTF-8?q?test(web):=20e2e=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D1=81-=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=87=D1=83=D0=B6=D0=BE=D0=BC=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B5=20(=C2=A711.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чек-лист §11.5 проверялся отдельными скриптами и живьём, но в спеках его не было. Новый файл (`GLCHAT_E2E_INSTANCE=1`) работает на сервере, где админ не участник: владелец — `rtprobe`, участник — `rtprobe2`. - 14: видимость — чужой приватный сервер в `GET /instance/guilds` и в списке серверов админа, комнаты, история, поиск, участники, роли, баны, инвайты, эмодзи, звуки, вебхуки, аудит; чужие DM по-прежнему 404; - 15: управление без 403 — имя сервера, ник владельца, роли (создание, поднятие выше своей, выдача и снятие), комнаты; аудит помечает действия `actor_instance_admin=true`; - 16: модерация — бан с причиной и автором, разбан, кик, тайм-аут; попытки владельца кикнуть/забанить/замутить/лишить роли админа дают 403 `instance.admin_protected`; - 17: обход лимитов — slowmode, приватность комнаты, лимит участников голосовой комнаты; - 18: UI — админ входит в чужой приватный сервер из рейки без инвайта, пишет в приватную комнату, а участник видит переименование и кик без перезагрузки; - 19: аудит — прежние записи не затираются; 19b (`test.fixme`) фиксирует пробел: переименование сервера, ник и комнаты в аудит не пишутся; - 20: безопасность не обходится — step-up на свежей сессии, чужой Origin, лимит реакций 429. Уборка тестовых серверов — в `finally` от имени админа (удаление чужого сервера требует step-up). --- web/e2e/instance-admin.spec.ts | 893 +++++++++++++++++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 web/e2e/instance-admin.spec.ts diff --git a/web/e2e/instance-admin.spec.ts b/web/e2e/instance-admin.spec.ts new file mode 100644 index 0000000..876f8c3 --- /dev/null +++ b/web/e2e/instance-admin.spec.ts @@ -0,0 +1,893 @@ +import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'; + +import { + adminCredentials, + apiAs, + createChannel, + createInvite, + displayName, + ensureProbeWithWait, + finishOnboarding, + freshAdminState, + freshState, + listChannels, + listRoles, + loginAsAdmin, + openChannel, + openDmChannel, + openGuild, + openWorkspace, + patchChannel, + pngBytes, + postMessage, + prepareGuild, + repeat429, + sessionFrom, + stepUpAsAdmin, + trackLoads, + type Account, + type Channel, + type GuildFixture, + type ProbeLogin, + type Session, +} from './support'; + +/** + * Чек-лист §11.5 и глобальная роль «Администратор сервера» (§7.19): инстанс-админ + * тестируется на чужом сервере, где он **не участник**. Часть проверок идёт по + * REST (сессия администратора через API-контекст), часть — в браузере (рейка, + * приватная комната, композер). Запуск только по флагу, тест требует стенда: + * + * GLCHAT_E2E_INSTANCE=1 GLCHAT_URL=https://gl.mhspx.su \ + * GLCHAT_ADMIN_EMAIL=admin@gl.mhspx.su GLCHAT_ADMIN_PASSWORD=... \ + * GLCHAT_ADMIN_TOTP=... PLAYWRIGHT_BROWSERS_PATH=../.cache/ms-playwright \ + * npx playwright test e2e/instance-admin.spec.ts + * + * Пункт «отзыв роли администратора через remove-admin немедленно снимает + * привилегии» сюда не входит: это команда CLI на самом стенде, а не API. + */ +const enabled = process.env.GLCHAT_E2E_INSTANCE === '1'; +const API = '/api/v1'; +const GUILD_PREFIX = 'Instance E2E '; +/** Постоянные пробные аккаунты: владелец чужого сервера и его участник. */ +const OWNER_LOGIN: ProbeLogin = { username: 'rtprobe', password: 'Rt-Probe-Owner-9x' }; +const MEMBER_LOGIN: ProbeLogin = { username: 'rtprobe2', password: 'Rt-Probe-Member-9x' }; +/** Секретная комната закрыта для @everyone: её видит только админ инстанса. */ +const PRIVATE_CHANNEL = 'тайная'; +/** Текст в секретной комнате: его находит поиск администратора. */ +const SECRET_TEXT = 'секрет инстанс-админа'; + +interface MemberPayload { + user_id: string; + nickname?: string; + timeout_until?: string; +} + +interface AuditPayload { + id: string; + actor_id?: string; + actor_instance_admin: boolean; + action: string; +} + +let owner: Account; +let member: Account; +let admin: Account; + +test.describe('инстанс-админ: чужой сервер (§11.5, §7.19)', () => { + test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_INSTANCE=1'); + test.skip( + adminCredentials().password === '' || adminCredentials().totp === '', + 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP', + ); + + // Лимит API — 120 запросов в минуту на сессию (AGENT.md 8.6): пауза между + // тестами даёт ведру лимита наполниться, иначе интерфейс получает 429. + test.afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 4_000)); + }); + + test.beforeAll(async ({ playwright }) => { + const ownerApi = await playwright.request.newContext({ baseURL: standBase() }); + const memberApi = await playwright.request.newContext({ baseURL: standBase() }); + const adminApi = await playwright.request.newContext({ baseURL: standBase() }); + try { + // Лимит входов — 5 в минуту на IP (AGENT.md 8.6): входы разносим по времени. + const adminUser = await loginAsAdmin(adminApi); + await finishOnboarding(adminApi); + await stepUpAsAdmin(adminApi); + // Уборка серверов прошлых прогонов: админ видит и удаляет чужие серверы. + await dropStaleByPrefix(adminApi, GUILD_PREFIX); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + const ownerProbe = await ensureProbeWithWait(ownerApi, OWNER_LOGIN); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const memberProbe = await ensureProbeWithWait(memberApi, MEMBER_LOGIN); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const adminBrowserState = await freshAdminState(adminApi); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + const memberBrowserState = await freshState(memberApi, MEMBER_LOGIN); + const ownerState = await ownerApi.storageState(); + + owner = { + ...ownerProbe, + displayName: await displayName(ownerApi), + state: ownerState, + browserState: ownerState, + }; + member = { + ...memberProbe, + displayName: await displayName(memberApi), + state: await memberApi.storageState(), + browserState: memberBrowserState, + }; + admin = { + id: adminUser.id, + username: 'admin', + email: adminCredentials().email, + password: adminCredentials().password, + displayName: 'admin', + state: await adminApi.storageState(), + browserState: adminBrowserState, + }; + } finally { + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('14. Видимость: чужой приватный сервер, комнаты, история, поиск и приватность DM', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const privateChannel = await createPrivateChannel(ownerApi, fixture); + const secretId = await postMessage(ownerApi, privateChannel.id, SECRET_TEXT); + + // Приватность подтверждаем от обычного участника: комнаты нет в списке, + // история закрыта — иначе «видимость админа» ничего не доказывает. + const memberChannels = await listChannels(memberApi, fixture.guildId); + expect( + memberChannels.map((channel) => channel.id), + 'обычный участник не видит приватную комнату', + ).not.toContain(privateChannel.id); + const memberHistory = await repeat429(() => + memberApi.get(`${API}/channels/${privateChannel.id}/messages`), + ); + expect( + memberHistory.status(), + 'история приватной комнаты закрыта для участника (сервер прячет её как 404)', + ).toBe(404); + + // Сервер в списке инстанса, хотя он приватный и чужой. + const instanceGuilds = await instanceGuildsOf(adminApi); + expect( + instanceGuilds.map((guild) => guild.id), + 'GET /instance/guilds содержит чужой приватный сервер', + ).toContain(fixture.guildId); + const publicNames = await publicGuildNames(memberApi); + expect(publicNames, 'приватный сервер не попадает в каталог публичных').not.toContain( + fixture.guildName, + ); + + // Админ не участник, но видит сервер, комнаты, участников и историю. + const guild = await repeat429(() => adminApi.get(`${API}/guilds/${fixture?.guildId ?? ''}`)); + expect(guild.status(), `чужой сервер: ${await guild.text()}`).toBe(200); + const channels = await listChannels(adminApi, fixture.guildId); + expect( + channels.map((channel) => channel.id), + 'админ видит приватную комнату в списке', + ).toContain(privateChannel.id); + + const history = await repeat429(() => + adminApi.get(`${API}/channels/${privateChannel.id}/messages`), + ); + expect(history.status(), `история приватной комнаты: ${await history.text()}`).toBe(200); + expect(JSON.stringify(await history.json())).toContain(SECRET_TEXT); + + // Поиск: админ ищет по приватной комнате и находит сообщение. + const search = await repeat429(() => + adminApi.get(`${API}/channels/${privateChannel.id}/messages/search?q=секрет`), + ); + expect(search.status(), `поиск: ${await search.text()}`).toBe(200); + expect(JSON.stringify(await search.json())).toContain(secretId); + + // Всё остальное хозяйство чужого сервера тоже доступно. + await createInvite(adminApi, fixture.guildId); + const members = await guildMembers(adminApi, fixture.guildId); + expect(members.map((item) => item.user_id)).toEqual( + expect.arrayContaining([owner.id, member.id]), + ); + for (const path of [ + 'roles', + 'bans', + 'invites', + 'emojis', + 'sounds', + 'cosmetics', + 'voice-states', + 'audit-log', + ]) { + const response = await repeat429(() => + adminApi.get(`${API}/guilds/${fixture?.guildId ?? ''}/${path}`), + ); + expect(response.status(), `чужой сервер, раздел ${path}: ${await response.text()}`).toBe( + 200, + ); + } + const webhooks = await repeat429(() => + adminApi.get(`${API}/channels/${privateChannel.id}/webhooks`), + ); + expect(webhooks.status(), `вебхуки комнаты: ${await webhooks.text()}`).toBe(200); + + // Инстанс-админ входит в чужой сервер из интерфейса: он есть и в READY + // (рейка), и в подстраховочном REST-списке. + const myGuilds = await repeat429(() => adminApi.get(`${API}/users/@me/guilds`)); + expect(myGuilds.status(), `мои серверы: ${await myGuilds.text()}`).toBe(200); + const myGuildIds = ((await myGuilds.json()) as { guilds: { id: string }[] }).guilds.map( + (item) => item.id, + ); + expect(myGuildIds, 'чужой сервер отдаётся и в списке «мои серверы»').toContain( + fixture.guildId, + ); + + // Исключение: чужие личные беседы админу недоступны (AGENT.md 7.19). + const dmId = await openDmChannel(ownerApi, member.id); + await postMessage(ownerApi, dmId, 'личное сообщение'); + const foreignDm = await repeat429(() => adminApi.get(`${API}/channels/${dmId}/messages`)); + expect(foreignDm.status(), 'чужая личная беседа не читается').toBe(404); + const foreignSend = await repeat429(() => + adminApi.post(`${API}/channels/${dmId}/messages`, { data: { content: 'подглядывание' } }), + ); + expect(foreignSend.status(), 'в чужую личную беседу не отправить').toBe(404); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('15. Управление чужим сервером без 403: имя, ник владельца, роли и комнаты', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const guildPath = `${API}/guilds/${fixture.guildId}`; + const renamed = `${fixture.guildName} (админ)`; + + // Имя сервера: админ правит чужой сервер, владелец видит новое имя. + const rename = await repeat429(() => adminApi.patch(guildPath, { data: { name: renamed } })); + expect(rename.ok(), `переименование чужого сервера: ${await rename.text()}`).toBeTruthy(); + const ownerView = await repeat429(() => ownerApi.get(guildPath)); + expect(JSON.stringify(await ownerView.json())).toContain(renamed); + + // Ник владельца: ник виден всем на сервере и не меняет логин. + const nickname = 'Ник от админа'; + const setNick = await repeat429(() => + adminApi.patch(`${guildPath}/members/${owner.id}`, { data: { nickname } }), + ); + expect(setNick.ok(), `ник владельца: ${await setNick.text()}`).toBeTruthy(); + const members = await guildMembers(ownerApi, fixture.guildId); + expect( + members.find((item) => item.user_id === owner.id)?.nickname, + 'ник виден владельцу', + ).toBe(nickname); + + // Иерархия игнорируется: админ создаёт роль с ADMINISTRATOR, поднимает её + // выше всех (позиция правится отдельной ручкой), выдаёт и снимает её, + // переименовывает роль владельца. + const elevated = await createRole(adminApi, fixture.guildId, { + name: 'Старшая роль', + permissions: 'ADMINISTRATOR', + }); + const raised = await repeat429(() => + adminApi.patch(`${guildPath}/roles/${elevated}`, { data: { position: 100 } }), + ); + expect(raised.ok(), `поднятие роли выше своей: ${await raised.text()}`).toBeTruthy(); + const assigned = await repeat429(() => + adminApi.put(`${guildPath}/members/${member.id}/roles/${elevated}`), + ); + expect(assigned.ok(), `выдача роли выше своей: ${await assigned.text()}`).toBeTruthy(); + const removed = await repeat429(() => + adminApi.delete(`${guildPath}/members/${member.id}/roles/${elevated}`), + ); + expect(removed.ok(), `снятие роли: ${await removed.text()}`).toBeTruthy(); + + const roles = await listRoles(ownerApi, fixture.guildId); + const ownerRole = roles.find( + (role) => !role.is_default && role.permissions.includes('ADMINISTRATOR'), + ); + expect(ownerRole, 'у владельца есть роль администратора').toBeTruthy(); + const renameRole = await repeat429(() => + adminApi.patch(`${guildPath}/roles/${ownerRole?.id ?? ''}`, { + data: { name: 'Роль владельца (админ)' }, + }), + ); + expect(renameRole.ok(), `правка роли владельца: ${await renameRole.text()}`).toBeTruthy(); + + // Комнаты: создание, правка статуса и удаление в чужом сервере. + const channel = await createChannel(adminApi, fixture.guildId, { + name: 'комната-админа', + type: 'text', + }); + await patchChannel(adminApi, fixture.guildId, channel.id, { + description: 'Статус от админа', + }); + const removeChannel = await repeat429(() => + adminApi.delete(`${API}/guilds/${fixture?.guildId ?? ''}/channels/${channel.id}`), + ); + expect(removeChannel.ok(), `удаление комнаты: ${await removeChannel.text()}`).toBeTruthy(); + + // Аудит: все эти действия помечены как действия инстанс-админа. + const audit = await auditLog(ownerApi, fixture.guildId); + const adminActions = audit.filter((entry) => entry.actor_id === admin.id); + expect(adminActions.length, 'действия админа попали в аудит').toBeGreaterThanOrEqual(3); + expect( + adminActions.every((entry) => entry.actor_instance_admin), + `в аудите actor_instance_admin=true: ${JSON.stringify(adminActions)}`, + ).toBe(true); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('16. Модерация чужого сервера и защита админа: кик/бан/мут — 403', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const guildPath = `${API}/guilds/${fixture.guildId}`; + + // Владелец не может ни забанить, ни кикнуть, ни замутить админа, ни + // снять с него роль: права инстанс-админа глобальны (AGENT.md 7.19). + const kick = await repeat429(() => ownerApi.delete(`${guildPath}/members/${admin.id}`)); + expect(kick.status(), `кик админа: ${await kick.text()}`).toBe(403); + expect(await errorCode(kick)).toBe('instance.admin_protected'); + + const ban = await repeat429(() => + ownerApi.put(`${guildPath}/bans/${admin.id}`, { data: { reason: 'нельзя' } }), + ); + expect(ban.status(), `бан админа: ${await ban.text()}`).toBe(403); + expect(await errorCode(ban)).toBe('instance.admin_protected'); + + const timeout = await repeat429(() => + ownerApi.patch(`${guildPath}/members/${admin.id}`, { + data: { timeout_until: new Date(Date.now() + 60_000).toISOString() }, + }), + ); + expect(timeout.status(), `тайм-аут админа: ${await timeout.text()}`).toBe(403); + expect(await errorCode(timeout)).toBe('instance.admin_protected'); + + const strip = await repeat429(() => + ownerApi.delete(`${guildPath}/members/${admin.id}/roles/${fixture?.everyoneRoleId ?? ''}`), + ); + expect(strip.status(), `снятие роли с админа: ${await strip.text()}`).toBe(403); + expect(await errorCode(strip)).toBe('instance.admin_protected'); + + // Модерация участника админом: бан с причиной виден в бан-листе. + const reason = 'проверка инстанс-админа'; + const banned = await repeat429(() => + adminApi.put(`${guildPath}/bans/${member.id}`, { data: { reason } }), + ); + expect(banned.ok(), `бан участника: ${await banned.text()}`).toBeTruthy(); + const banList = await repeat429(() => adminApi.get(`${guildPath}/bans`)); + const bans = (await banList.json()) as { + bans: { user_id: string; reason: string; actor_id?: string }[]; + }; + const entry = bans.bans.find((item) => item.user_id === member.id); + expect(entry?.reason, 'причина бана сохранена').toBe(reason); + expect(entry?.actor_id, 'автор бана — админ').toBe(admin.id); + + // Забаненный не может вернуться, разбан возвращает доступ. + const joinBanned = await repeat429(() => memberApi.post(`${guildPath}/join`)); + expect(joinBanned.status(), 'забаненный не входит в сервер').toBe(403); + const unban = await repeat429(() => adminApi.delete(`${guildPath}/bans/${member.id}`)); + expect(unban.ok(), `разбан: ${await unban.text()}`).toBeTruthy(); + await joinTemporarily(ownerApi, memberApi, fixture); + const memberView = await repeat429(() => memberApi.get(guildPath)); + expect(memberView.status(), 'разбан вернул доступ').toBe(200); + + // Кик применяется сразу: участник теряет доступ к серверу (приватный + // сервер не-участнику сервер отдаёт как 404, чтобы не раскрывать его). + const kicked = await repeat429(() => adminApi.delete(`${guildPath}/members/${member.id}`)); + expect(kicked.ok(), `кик участника: ${await kicked.text()}`).toBeTruthy(); + const afterKick = await repeat429(() => memberApi.get(guildPath)); + expect(afterKick.status(), 'кикнутый не видит сервер').toBe(404); + + // Тайм-аут участника: писать нельзя, время видно в списке участников. + // Роль с ADMINISTRATOR тайм-аут не отменяет только у владельца, поэтому + // проверяем на обычном участнике (AGENT.md 7.10, 7.17). + await joinTemporarily(ownerApi, memberApi, fixture); + const until = new Date(Date.now() + 120_000).toISOString(); + const muted = await repeat429(() => + adminApi.patch(`${guildPath}/members/${member.id}`, { data: { timeout_until: until } }), + ); + expect(muted.ok(), `тайм-аут участника: ${await muted.text()}`).toBeTruthy(); + const memberList = await guildMembers(ownerApi, fixture.guildId); + expect(memberList.find((item) => item.user_id === member.id)?.timeout_until).toBeTruthy(); + const mutedPost = await memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { + data: { content: 'в тайм-ауте' }, + }); + expect(mutedPost.status(), 'в тайм-ауте писать нельзя').toBe(403); + // Тайм-аут снимается администратором: время в прошлом возвращает право. + const cleared = await repeat429(() => + adminApi.patch(`${guildPath}/members/${member.id}`, { + data: { timeout_until: new Date(Date.now() - 60_000).toISOString() }, + }), + ); + expect(cleared.ok(), `снятие тайм-аута: ${await cleared.text()}`).toBeTruthy(); + const restored = await memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { + data: { content: 'тайм-аут снят' }, + }); + expect( + restored.ok(), + `после снятия тайм-аута писать можно: ${await restored.text()}`, + ).toBeTruthy(); + + // Иерархия: тайм-аут на владельца админ тоже ставит без 403 (эффект + // проверяем на участнике: у владельца роль ADMINISTRATOR). + const ownerMute = await repeat429(() => + adminApi.patch(`${guildPath}/members/${owner.id}`, { + data: { timeout_until: new Date(Date.now() + 120_000).toISOString() }, + }), + ); + expect(ownerMute.ok(), `тайм-аут владельца: ${await ownerMute.text()}`).toBeTruthy(); + await repeat429(() => + adminApi.patch(`${guildPath}/members/${owner.id}`, { + data: { timeout_until: new Date(Date.now() - 60_000).toISOString() }, + }), + ); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('17. Обход продуктовых лимитов: slowmode, приватность комнаты, лимит участников', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const voice = await createChannel(ownerApi, fixture.guildId, { + name: 'Голос', + type: 'voice', + user_limit: 1, + }); + const privateChannel = await createPrivateChannel(ownerApi, fixture); + + // Slowmode: участник упирается в лимит, админ пишет подряд без 429. + // Здесь нельзя повторять запрос по 429 — slowmode и есть проверяемый лимит. + await patchChannel(ownerApi, fixture.guildId, fixture.textChannelId, { + slowmode_seconds: 60, + }); + const first = await memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { + data: { content: 'первое' }, + }); + expect(first.ok(), `первое сообщение участника: ${await first.text()}`).toBeTruthy(); + const second = await memberApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { + data: { content: 'второе' }, + }); + expect(second.status(), 'slowmode действует на участника').toBe(429); + for (const text of ['админ 1', 'админ 2', 'админ 3']) { + const burst = await adminApi.post(`${API}/channels/${fixture.textChannelId}/messages`, { + data: { content: text }, + }); + expect(burst.ok(), `админ обходит slowmode и антиспам: ${await burst.text()}`).toBeTruthy(); + } + + // Приватная комната: участник её не видит, админ в неё пишет. + const intoPrivate = await adminApi.post(`${API}/channels/${privateChannel.id}/messages`, { + data: { content: 'админ в приватной' }, + }); + expect(intoPrivate.ok(), `админ пишет в приватную: ${await intoPrivate.text()}`).toBeTruthy(); + + // Лимит участников комнаты: второй обычный участник не входит, админ — да. + const memberJoin = await repeat429(() => + memberApi.post(`${API}/channels/${voice.id}/voice/join`), + ); + expect(memberJoin.ok(), `участник в голосовой: ${await memberJoin.text()}`).toBeTruthy(); + const ownerJoin = await repeat429(() => + ownerApi.post(`${API}/channels/${voice.id}/voice/join`), + ); + expect(ownerJoin.status(), 'лимит комнаты действует на владельца').toBe(403); + expect(await errorCode(ownerJoin)).toBe('voice.channel_full'); + const adminJoin = await repeat429(() => + adminApi.post(`${API}/channels/${voice.id}/voice/join`), + ); + expect(adminJoin.ok(), `админ входит сверх лимита: ${await adminJoin.text()}`).toBeTruthy(); + // Голосовые состояния за собой убираем: они видны всему серверу. + await repeat429(() => adminApi.post(`${API}/channels/${voice.id}/voice/leave`)); + await repeat429(() => memberApi.post(`${API}/channels/${voice.id}/voice/leave`)); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('18. UI: админ входит в чужой приватный сервер без инвайта и пишет в него', async ({ + browser, + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let adminSession: Session | null = null; + let memberSession: Session | null = null; + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const privateChannel = await createPrivateChannel(ownerApi, fixture); + const seededText = 'история приватной комнаты'; + await postMessage(ownerApi, privateChannel.id, seededText); + await postMessage(ownerApi, fixture.textChannelId, 'общая комната'); + + // Админ не участник сервера, но сервер есть в его READY: рейка показывает + // чужой сервер, а сайдбар — приватную комнату (AGENT.md 11.5). + adminSession = await sessionFrom(browser, admin.browserState); + const adminLoads = trackLoads(adminSession.page); + await openGuild(adminSession.page, fixture.guildName); + await expect(adminSession.page.getByTestId('guild-header')).toContainText(fixture.guildName, { + timeout: 30_000, + }); + await openChannel(adminSession.page, PRIVATE_CHANNEL); + await expect(adminSession.page.getByTestId('message-list')).toContainText(seededText, { + timeout: 30_000, + }); + adminLoads.reset(); + + // Композер доступен: админ пишет в приватную комнату чужого сервера. + const text = `сообщение админа ${Date.now()}`; + const composer = adminSession.page.getByTestId('composer').locator('textarea').first(); + await composer.fill(text); + await composer.press('Enter'); + await expect(adminSession.page.getByTestId('message-list')).toContainText(text, { + timeout: 20_000, + }); + await expect + .poll(async () => JSON.stringify(await channelMessages(ownerApi, privateChannel.id)), { + timeout: 20_000, + }) + .toContain(text); + expect(adminLoads.count(), 'админ ходил по чужому серверу без перезагрузки').toBe(0); + + // Обычный участник видит изменения администратора без F5: переименование + // сервера приходит событием, кик убирает сервер из рейки (AGENT.md 11.5). + memberSession = await sessionFrom(browser, member.browserState); + await openWorkspace(memberSession.page, fixture); + const memberLoads = trackLoads(memberSession.page); + memberLoads.reset(); + const renamed = `${fixture.guildName} (переименован админом)`; + const rename = await repeat429(() => + adminApi.patch(`${API}/guilds/${fixture?.guildId ?? ''}`, { data: { name: renamed } }), + ); + expect(rename.ok(), `переименование: ${await rename.text()}`).toBeTruthy(); + await expect(memberSession.page.getByTestId('guild-header')).toContainText(renamed, { + timeout: 20_000, + }); + + const kick = await repeat429(() => + adminApi.delete(`${API}/guilds/${fixture?.guildId ?? ''}/members/${member.id}`), + ); + expect(kick.ok(), `кик участника: ${await kick.text()}`).toBeTruthy(); + await expect( + memberSession.page.getByRole('link', { name: `Открыть сервер ${renamed}`, exact: true }), + ).toHaveCount(0, { timeout: 20_000 }); + expect(memberLoads.count(), 'участник видел изменения без перезагрузки').toBe(0); + } finally { + await adminSession?.context.close(); + await memberSession?.context.close(); + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('19. Аудит: действия админа помечены, прежние записи не затираются', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const guildPath = `${API}/guilds/${fixture.guildId}`; + // Своё действие владельца: оно должно остаться в журнале после админа. + await createRole(ownerApi, fixture.guildId, { name: 'Роль владельца' }); + const before = await auditLog(ownerApi, fixture.guildId); + const ownEntry = before.find( + (entry) => entry.action === 'role.create' && entry.actor_id === owner.id, + ); + expect(ownEntry, 'создание роли владельцем попало в аудит').toBeTruthy(); + + // Действия администратора из списка §11.5, которые пишутся в аудит: + // роли (создание, правка, выдача), инвайты, эмодзи. + const roleId = await createRole(adminApi, fixture.guildId, { name: 'Роль от админа' }); + await repeat429(() => + adminApi.patch(`${guildPath}/roles/${roleId}`, { data: { name: 'Роль от админа 2' } }), + ); + await repeat429(() => adminApi.put(`${guildPath}/members/${member.id}/roles/${roleId}`)); + await createInvite(adminApi, fixture.guildId); + await repeat429(() => + adminApi.post(`${API}/guilds/${fixture?.guildId ?? ''}/emojis`, { + multipart: { + name: 'admin_emoji', + file: { name: 'emoji.png', mimeType: 'image/png', buffer: pngBytes(32, 32, [7, 7, 7]) }, + }, + }), + ); + + const after = await auditLog(ownerApi, fixture.guildId); + expect( + after.map((entry) => entry.id), + 'прежняя запись аудита не затёрта', + ).toContain(ownEntry?.id ?? ''); + const adminEntries = after.filter((entry) => entry.actor_id === admin.id); + expect(adminEntries.length, 'действия админа записаны').toBeGreaterThanOrEqual(4); + for (const entry of adminEntries) { + expect(entry.actor_instance_admin, `запись ${entry.action} помечена`).toBe(true); + } + // Действия обычного владельца остаются без пометки администратора. + const ownerEntries = after.filter((entry) => entry.actor_id === owner.id); + expect(ownerEntries.every((entry) => !entry.actor_instance_admin)).toBe(true); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + /** + * Известный пробел §11.5: «каждое действие пишется в аудит» выполняется не для + * всех мутаций. Переименование сервера, смена ника участника и создание, + * правка и удаление комнаты не пишут запись в аудит ни администратору, ни + * владельцу (в `internal/server/api_guilds.go` у этих ручек нет `recordAudit`). + * Тест оставлен как fixme с точной диагностикой: когда записи появятся, его + * достаточно раскомментировать. + */ + test.fixme('19b. Аудит: переименование сервера, ник и комнаты тоже попадают в журнал', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const guildPath = `${API}/guilds/${fixture.guildId}`; + await repeat429(() => adminApi.patch(guildPath, { data: { name: 'Аудит E2E' } })); + await repeat429(() => + adminApi.patch(`${guildPath}/members/${owner.id}`, { data: { nickname: 'в аудите' } }), + ); + const channel = await createChannel(adminApi, fixture.guildId, { + name: 'комната-админа', + type: 'text', + }); + await patchChannel(adminApi, fixture.guildId, channel.id, { description: 'правка админа' }); + await repeat429(() => adminApi.delete(`${guildPath}/channels/${channel.id}`)); + + const after = await auditLog(ownerApi, fixture.guildId); + const actions = after + .filter((entry) => entry.actor_id === admin.id) + .map((entry) => entry.action); + expect(actions, 'в аудите есть правка сервера, ника и комнат').toEqual( + expect.arrayContaining(['guild.update', 'member.update', 'channel.create']), + ); + } finally { + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); + + test('20. Безопасность не обходится: step-up, Origin и лимит реакций действуют', async ({ + playwright, + }) => { + const ownerApi = await apiAs(playwright, owner.state); + const memberApi = await apiAs(playwright, member.state); + const adminApi = await apiAs(playwright, admin.state); + // Свежая сессия администратора (отдельный вход): step-up на ней не проходил, + // поэтому удаление сервера обязано упереться в подтверждение личности. + const freshAdminApi = await apiAs(playwright, await freshAdminState(adminApi)); + let fixture: GuildFixture | null = null; + + try { + fixture = await prepareGuild(ownerApi, memberApi, `${GUILD_PREFIX}${Date.now()}`); + const guildPath = `${API}/guilds/${fixture.guildId}`; + + // Свежая сессия: удаление сервера требует подтверждения личности. + const noStepUp = await repeat429(() => freshAdminApi.delete(guildPath)); + expect(noStepUp.status(), `удаление без step-up: ${await noStepUp.text()}`).toBe(403); + expect(await errorCode(noStepUp)).toBe('auth.step_up_required'); + + // Чужой Origin отклоняется и для администратора: проверка стоит на + // изменяющих методах (GET чужой Origin не ломает, AGENT.md 9.5). + const foreignOrigin = await adminApi.post(`${API}/channels/${fixture.textChannelId}/typing`, { + headers: { origin: 'https://evil.example' }, + }); + expect(foreignOrigin.status(), 'чужой Origin отклонён').toBe(403); + + // Rate limiting: лимит реакций (20 в 20 секунд) не обходится админом. + const messageId = await postMessage(ownerApi, fixture.textChannelId, 'мишень для реакций'); + let limited: APIResponse | null = null; + for (let index = 0; index < 25 && limited === null; index += 1) { + const reaction = await adminApi.put( + `${API}/channels/${fixture.textChannelId}/messages/${messageId}/reactions/👍`, + ); + if (reaction.status() === 429) { + limited = reaction; + } + } + expect(limited, 'лимит реакций вернул 429 и администратору').not.toBeNull(); + expect(await errorCode(limited as APIResponse)).toBe('rate_limited'); + } finally { + await freshAdminApi.dispose(); + await cleanupAdminGuild(adminApi, fixture); + await ownerApi.dispose(); + await memberApi.dispose(); + await adminApi.dispose(); + } + }); +}); + +/** standBase — адрес стенда: тот же, что у Playwright (GLCHAT_URL). */ +function standBase(): string { + return process.env.GLCHAT_URL ?? 'https://gl.mhspx.su'; +} + +/** errorCode читает код ошибки из единого конверта API (AGENT.md 8.1). */ +async function errorCode(response: APIResponse): Promise { + const payload = (await response.json().catch(() => ({}))) as { error?: { code?: string } }; + return payload.error?.code ?? ''; +} + +/** dropStaleByPrefix удаляет серверы прошлых прогонов по префиксу имени. */ +async function dropStaleByPrefix(api: APIRequestContext, prefix: string): Promise { + const response = await api.get(`${API}/users/@me/guilds`); + if (!response.ok()) { + return; + } + const { guilds } = (await response.json()) as { guilds: { id: string; name: string }[] }; + for (const guild of guilds) { + if (guild.name.startsWith(prefix)) { + await api.delete(`${API}/guilds/${guild.id}`); + } + } +} + +async function instanceGuildsOf(api: APIRequestContext): Promise<{ id: string; name: string }[]> { + const response = await repeat429(() => api.get(`${API}/instance/guilds`)); + expect(response.ok(), `серверы инстанса: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { guilds: { id: string; name: string }[] }).guilds; +} + +/** publicGuildNames читает каталог открытых серверов (AGENT.md 7.20). */ +async function publicGuildNames(api: APIRequestContext): Promise { + const response = await repeat429(() => api.get(`${API}/guilds/public`)); + expect(response.ok(), `каталог серверов: ${await response.text()}`).toBeTruthy(); + const payload = (await response.json()) as { guilds: { name: string }[] }; + return payload.guilds.map((guild) => guild.name); +} + +async function guildMembers(api: APIRequestContext, guildId: string): Promise { + const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/members`)); + expect(response.ok(), `участники сервера: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { members: MemberPayload[] }).members; +} + +async function auditLog(api: APIRequestContext, guildId: string): Promise { + const response = await repeat429(() => api.get(`${API}/guilds/${guildId}/audit-log?limit=200`)); + expect(response.ok(), `аудит сервера: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { entries: AuditPayload[] }).entries; +} + +async function channelMessages(api: APIRequestContext, channelId: string): Promise { + const response = await repeat429(() => api.get(`${API}/channels/${channelId}/messages`)); + if (!response.ok()) { + return []; + } + return ((await response.json()) as { messages: unknown[] }).messages; +} + +/** createRole создаёт роль в чужом сервере и возвращает её id. */ +async function createRole( + api: APIRequestContext, + guildId: string, + body: Record, +): Promise { + const response = await repeat429(() => + api.post(`${API}/guilds/${guildId}/roles`, { data: body }), + ); + expect(response.ok(), `создание роли: ${await response.text()}`).toBeTruthy(); + return ((await response.json()) as { role: { id: string } }).role.id; +} + +/** + * createPrivateChannel создаёт комнату, закрытую для @everyone: её видит + * только администратор инстанса, обычные участники — нет (AGENT.md 6.2). + */ +async function createPrivateChannel( + ownerApi: APIRequestContext, + fixture: GuildFixture, +): Promise { + const channel = await createChannel(ownerApi, fixture.guildId, { + name: PRIVATE_CHANNEL, + type: 'text', + }); + const overwrite = await repeat429(() => + ownerApi.put( + `${API}/guilds/${fixture.guildId}/channels/${channel.id}/overwrites/role/${fixture.everyoneRoleId}`, + { data: { deny: 'VIEW_CHANNEL', step_up_password: OWNER_LOGIN.password } }, + ), + ); + expect(overwrite.ok(), `закрытие комнаты для @everyone: ${await overwrite.text()}`).toBeTruthy(); + return channel; +} + +/** joinTemporarily возвращает участника в сервер: он приватный, вход — открытием. */ +async function joinTemporarily( + ownerApi: APIRequestContext, + memberApi: APIRequestContext, + fixture: GuildFixture, +): Promise { + const opened = await repeat429(() => + ownerApi.patch(`${API}/guilds/${fixture.guildId}`, { data: { public: true } }), + ); + expect(opened.ok(), `открытие сервера: ${await opened.text()}`).toBeTruthy(); + const joined = await repeat429(() => memberApi.post(`${API}/guilds/${fixture.guildId}/join`)); + expect(joined.ok(), `возврат в сервер: ${await joined.text()}`).toBeTruthy(); + const closed = await repeat429(() => + ownerApi.patch(`${API}/guilds/${fixture.guildId}`, { data: { public: false } }), + ); + expect(closed.ok(), `закрытие сервера: ${await closed.text()}`).toBeTruthy(); +} + +/** + * cleanupAdminGuild удаляет тестовый сервер от имени админа: сервер создавал + * пробный владелец, а удаление чужого сервера требует step-up (AGENT.md 9.3). + */ +async function cleanupAdminGuild( + adminApi: APIRequestContext, + fixture: GuildFixture | null, +): Promise { + if (fixture === null) { + return; + } + await stepUpAsAdmin(adminApi).catch(() => undefined); + await repeat429(() => adminApi.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined); +}