Files
glchat/web/e2e/support.ts
T
grendervill 785af0dc7b feat(guilds): приватные комнаты — права доступа поверх серверных
Сервер:
- store: оверрайды всех комнат сервера одним запросом (без N+1) и снятие
  оверрайда с признаком «был ли он»;
- API: PUT/DELETE /guilds/{id}/channels/{cid}/overwrites/{role|user}/{tid}
  (права именами через `|`, как у ролей), step-up на изменение, проверка что
  роль принадлежит серверу, а участник состоит в нём;
- список комнат отдаёт permission_overwrites; после правки сбрасывается кэш
  прав комнаты, пишется аудит (channel.overwrite_set/delete) и уходит событие
  GUILD_CHANNELS_SYNC — видимость комнаты меняется у всех участников.

Клиент:
- редактор «Доступ к комнате»: приватность одним переключателем (запрет
  VIEW_CHANNEL для @everyone), права просмотра/переписки/входа для ролей и
  участников, подтверждение личности по требованию сервера;
- в настройках комнаты теперь и голосовые комнаты (фон и права), вебхуки —
  только у текстовых; событие GUILD_CHANNELS_SYNC перечитывает список комнат.

Тесты: 2 Go-теста (скрытие и открытие комнаты ролями, проверка цели и прав) и
3 web-теста редактора (маски, step-up, скрытие без MANAGE_ROLES).
2026-09-22 19:14:05 +03:00

301 lines
13 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 { createHmac } from 'node:crypto';
import { deflateSync } from 'node:zlib';
import {
expect,
type APIRequestContext,
type Browser,
type BrowserContext,
type Page,
} from '@playwright/test';
/**
* support.ts — общие помощники живых e2e против развёрнутого инстанса
* (AGENT.md 11.2, 11.6): вход по 2FA, повтор при лимите частоты, постоянные
* тестовые аккаунты, подготовка сервера и генерация картинок для загрузок.
*/
/** Значения окружения: без них живые тесты пропускаются. */
export function adminCredentials(): { email: string; password: string; totp: string } {
return {
email: process.env.GLCHAT_ADMIN_EMAIL ?? '',
password: process.env.GLCHAT_ADMIN_PASSWORD ?? '',
totp: process.env.GLCHAT_ADMIN_TOTP ?? '',
};
}
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
export function totp(secret: string, now = Date.now()): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const symbol of secret.replace(/=+$/u, '').toUpperCase()) {
const index = alphabet.indexOf(symbol);
if (index < 0) {
continue;
}
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(Number.parseInt(bits.slice(i, i + 8), 2));
}
const counter = Math.floor(now / 1000 / 30);
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
buffer.writeUInt32BE(counter % 2 ** 32, 4);
const digest = createHmac('sha1', Buffer.from(bytes)).update(buffer).digest();
const offset = digest.readUInt8(digest.length - 1) & 0x0f;
const value =
((digest.readUInt8(offset) & 0x7f) << 24) |
(digest.readUInt8(offset + 1) << 16) |
(digest.readUInt8(offset + 2) << 8) |
digest.readUInt8(offset + 3);
return (value % 1_000_000).toString().padStart(6, '0');
}
/**
* postWithRetry повторяет запрос при срабатывании лимита частоты: сервер
* отвечает `rate_limited` и `retry_after_ms`, а тест не должен падать из-за
* защиты, которая как раз и обязана работать (AGENT.md 9.4).
*/
export async function postWithRetry(
api: APIRequestContext,
path: string,
data: Record<string, unknown>,
attempts = 6,
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
let response = await api.post(path, { data });
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000);
await new Promise((resolve) => setTimeout(resolve, wait + 200));
response = await api.post(path, { data });
}
return response;
}
/**
* finishOnboarding помечает первичную настройку пройденной: иначе AuthGuard
* уводит браузер с `/app` на `/onboarding`.
*/
export async function finishOnboarding(api: APIRequestContext): Promise<void> {
const me = await api.get('/api/v1/users/@me');
expect(me.ok(), `текущий пользователь: ${await me.text()}`).toBeTruthy();
const { user } = (await me.json()) as {
user: { onboarding_completed: boolean; username: string; display_name?: string | null };
};
if (user.onboarding_completed) {
return;
}
const done = await api.post('/api/v1/users/@me/onboarding/complete', {
data: { display_name: user.display_name ?? user.username },
});
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
}
/** loginAsAdmin логинит api-контекст под инстанс-администратором. */
export async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
const admin = adminCredentials();
const response = await postWithRetry(api, '/api/v1/auth/login', {
email: admin.email,
password: admin.password,
totp_code: totp(admin.totp),
});
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { id: string } };
return payload.user;
}
export interface Probe {
id: string;
username: string;
email: string;
password: string;
}
/**
* ensureProbe логинит постоянный тестовый аккаунт, а если его ещё нет —
* регистрирует. Один и тот же аккаунт переиспользуется между прогонами: иначе
* каждый запуск оставлял бы в базе нового пользователя.
*/
export async function ensureProbe(
api: APIRequestContext,
username: string,
password: string,
): Promise<Probe> {
const email = `${username}@gl.mhspx.su`;
const credentials = { email, password };
let login = await postWithRetry(api, '/api/v1/auth/login', credentials);
if (!login.ok() && login.status() !== 429) {
const registered = await postWithRetry(api, '/api/v1/auth/register', {
username,
...credentials,
});
// 409 — аккаунт уже существует (например, гонка прогонов): просто входим.
expect(
registered.ok() || registered.status() === 409,
`тестовый пользователь ${username} не зарегистрирован: ${await registered.text()}`,
).toBeTruthy();
login = await postWithRetry(api, '/api/v1/auth/login', credentials);
}
expect(login.ok(), `вход тестового пользователя ${username}: ${await login.text()}`).toBeTruthy();
const payload = (await login.json()) as { user: { id: string } };
await finishOnboarding(api);
return { id: payload.user.id, username, email, password };
}
export interface SignInOptions {
/** Параметры контекста: разрешения, viewport и прочее. */
contextOptions?: Parameters<Browser['newContext']>[0];
/** Хук до создания страницы: сюда вешают init-скрипты (например, патч WebRTC). */
onContext?: (context: BrowserContext) => Promise<void>;
}
/** signIn логинит браузерный контекст и открывает приложение. */
export async function signIn(
browser: Browser,
options: { email: string; password: string; totp?: string },
extra: SignInOptions = {},
): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext(extra.contextOptions ?? {});
if (extra.onContext !== undefined) {
await extra.onContext(context);
}
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Почта', { exact: true }).fill(options.email);
await page.getByLabel('Пароль', { exact: true }).fill(options.password);
// Лимит частоты входов может сработать на серии прогонов: тогда форма
// показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
for (let attempt = 1; attempt <= 4; attempt += 1) {
if (options.totp !== undefined) {
await page.getByLabel('Код 2FA или резервный код').fill(totp(options.totp));
}
await page.getByRole('button', { name: /Войти/u }).click();
try {
await expect(page).toHaveURL(/\/app/u, { timeout: 25_000 });
return { context, page };
} catch (error) {
if (attempt === 4) {
throw error;
}
await page.waitForTimeout(8_000);
}
}
throw new Error('вход не удался');
}
/**
* dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал
* до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени.
*/
export async function dropStaleGuilds(api: APIRequestContext, prefix: string): Promise<void> {
const response = await api.get('/api/v1/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/v1/guilds/${guild.id}`);
}
}
}
/** createGuild создаёт сервер от имени владельца и возвращает его id. */
export async function createGuild(
api: APIRequestContext,
name: string,
): Promise<{ id: string; name: string }> {
const response = await api.post('/api/v1/guilds', { data: { name } });
expect(response.ok(), `создание сервера: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { guild: { id: string } };
return { id: payload.guild.id, name };
}
/** createInvite создаёт приглашение в сервер и возвращает код. */
export async function createInvite(api: APIRequestContext, guildId: string): Promise<string> {
const response = await api.post(`/api/v1/guilds/${guildId}/invites`, {
data: { max_uses: 0, max_age_seconds: 3600 },
});
expect(response.ok(), `создание приглашения: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { invite: { code: string } };
return payload.invite.code;
}
/** joinGuild вступает в сервер по коду приглашения. */
export async function joinGuild(api: APIRequestContext, code: string): Promise<void> {
const response = await postWithRetry(api, `/api/v1/invites/${code}`, {});
expect(response.ok(), `принятие приглашения: ${await response.text()}`).toBeTruthy();
}
/**
* trackLoads считает полные загрузки документа: SPA-переходы их не создают,
* поэтому нулевой счётчик после подготовки доказывает, что ни один шаг
* матрицы 11.6 не потребовал F5.
*/
export function trackLoads(page: Page): { count: () => number; reset: () => void } {
let loads = 0;
page.on('load', () => {
loads += 1;
});
return { count: () => loads, reset: () => (loads = 0) };
}
/** openGuild открывает сервер в сайдбаре. */
export async function openGuild(page: Page, guildName: string): Promise<void> {
await page.getByRole('link', { name: `Открыть сервер ${guildName}`, exact: true }).click();
}
/** openChannel открывает комнату сервера по имени. */
export async function openChannel(page: Page, channelName: string): Promise<void> {
await page.getByRole('link', { name: new RegExp(`Открыть комнату ${channelName}`, 'u') }).click();
}
/**
* pngBytes собирает PNG заданного размера и цвета: сервер проверяет реальный
* формат картинки, поэтому подделкой заголовков не обойтись.
*/
export function pngBytes(width: number, height: number, rgb: [number, number, number]): Buffer {
const raw = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y += 1) {
const rowStart = y * (width * 3 + 1);
raw[rowStart] = 0;
for (let x = 0; x < width; x += 1) {
const at = rowStart + 1 + x * 3;
raw[at] = rgb[0];
raw[at + 1] = rgb[1];
raw[at + 2] = rgb[2];
}
}
const chunk = (type: string, data: Buffer): Buffer => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([length, body, crc]);
};
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 2;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', header),
chunk('IDAT', deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}