5176b277a6
Чек-лист §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).
894 lines
45 KiB
TypeScript
894 lines
45 KiB
TypeScript
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);
|
||
}
|