Files
glchat/web/e2e/instance-admin.spec.ts
T
grendervill 5176b277a6 test(web): e2e инстанс-админа на чужом сервере (§11.5)
Чек-лист §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).
2026-09-26 11:06:42 +03:00

894 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
const payload = (await response.json().catch(() => ({}))) as { error?: { code?: string } };
return payload.error?.code ?? '';
}
/** dropStaleByPrefix удаляет серверы прошлых прогонов по префиксу имени. */
async function dropStaleByPrefix(api: APIRequestContext, prefix: string): Promise<void> {
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<string[]> {
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<MemberPayload[]> {
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<AuditPayload[]> {
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<unknown[]> {
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<string, unknown>,
): Promise<string> {
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<Channel> {
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<void> {
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<void> {
if (fixture === null) {
return;
}
await stepUpAsAdmin(adminApi).catch(() => undefined);
await repeat429(() => adminApi.delete(`${API}/guilds/${fixture.guildId}`)).catch(() => undefined);
}