Files
glchat/web/e2e/instance-admin.spec.ts
T

894 lines
45 KiB
TypeScript
Raw Normal View History

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