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); }