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).
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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;
|
||||
}
|
||||
+39
-192
@@ -1,4 +1,3 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
@@ -8,6 +7,15 @@ import {
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import {
|
||||
adminCredentials,
|
||||
dropStaleGuilds,
|
||||
ensureProbe,
|
||||
finishOnboarding,
|
||||
loginAsAdmin,
|
||||
signIn,
|
||||
} from './support';
|
||||
|
||||
/**
|
||||
* Голосовой e2e: два реальных браузера входят в одну голосовую комнату и
|
||||
* проверяется, что аудио действительно доходит через LiveKit (AGENT.md 11.2,
|
||||
@@ -19,40 +27,9 @@ import {
|
||||
* npx playwright test
|
||||
*/
|
||||
const enabled = process.env.GLCHAT_E2E_VOICE === '1';
|
||||
const ADMIN_EMAIL = process.env.GLCHAT_ADMIN_EMAIL ?? '';
|
||||
const ADMIN_PASSWORD = process.env.GLCHAT_ADMIN_PASSWORD ?? '';
|
||||
const ADMIN_TOTP = process.env.GLCHAT_ADMIN_TOTP ?? '';
|
||||
/** Постоянный тестовый пользователь: создаётся один раз и переиспользуется. */
|
||||
const PROBE_LOGIN = 'voiceprobe';
|
||||
|
||||
/** totp генерирует шестизначный код из секрета (RFC 6238, SHA-1, 30 секунд). */
|
||||
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');
|
||||
}
|
||||
const admin = adminCredentials();
|
||||
|
||||
interface Fixture {
|
||||
guildId: string;
|
||||
@@ -64,148 +41,35 @@ interface Fixture {
|
||||
guestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* dropStaleGuilds удаляет тестовые серверы прошлых запусков: если прогон упал
|
||||
* до уборки, они остаются в рейле и мешают выбрать нужный сервер по имени.
|
||||
*/
|
||||
async function dropStaleGuilds(api: APIRequestContext): 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('Voice E2E ')) {
|
||||
await api.delete(`/api/v1/guilds/${guild.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* finishOnboarding помечает первичную настройку пройденной, если она ещё не
|
||||
* завершена: иначе AuthGuard уводит браузер с `/app` на `/onboarding` и тест
|
||||
* не доходит до голосовой комнаты. Пустое тело запроса ничего не перезаписывает.
|
||||
*/
|
||||
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-контекст под инстанс-администратором и возвращает
|
||||
* его профиль. Код 2FA одноразовый в пределах окна, поэтому администратор
|
||||
* логинится ровно один раз — отдельным контекстом, который гостевые действия
|
||||
* не переключают на другого пользователя.
|
||||
*/
|
||||
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* postWithRetry повторяет запрос, если сработал лимит частоты: сервер отвечает
|
||||
* `rate_limited` и `retry_after_ms`, а тест не должен падать из-за защиты,
|
||||
* которая как раз и обязана работать (AGENT.md 9.4).
|
||||
*/
|
||||
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), 15_000);
|
||||
await new Promise((resolve) => setTimeout(resolve, wait + 200));
|
||||
response = await api.post(path, { data });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureProbeGuest логинит постоянного тестового пользователя, а если его ещё
|
||||
* нет — регистрирует. Один и тот же аккаунт переиспользуется между прогонами:
|
||||
* иначе каждый запуск оставлял бы в базе нового пользователя, а удалять
|
||||
* пользователей API пока не умеет (это задача админ-панели Фазы 5).
|
||||
*
|
||||
* Важно различать «пользователя нет» (401 — тогда регистрируем) и «сработал
|
||||
* лимит частоты» (429 — ждём и повторяем, иначе тест падал бы из-за защиты,
|
||||
* которая обязана работать).
|
||||
*/
|
||||
async function ensureProbeGuest(
|
||||
guest: APIRequestContext,
|
||||
): Promise<{ id: string; email: string; password: string }> {
|
||||
const email = `${PROBE_LOGIN}@gl.mhspx.su`;
|
||||
const password = 'Voice-Probe-Password-9x';
|
||||
const credentials = { email, password };
|
||||
let login = await postWithRetry(guest, '/api/v1/auth/login', credentials);
|
||||
if (login.status() === 429) {
|
||||
throw new Error(`вход тестового пользователя упёрся в лимит частоты: ${await login.text()}`);
|
||||
}
|
||||
if (!login.ok()) {
|
||||
const registered = await postWithRetry(guest, '/api/v1/auth/register', {
|
||||
username: PROBE_LOGIN,
|
||||
...credentials,
|
||||
});
|
||||
expect(
|
||||
registered.ok() || registered.status() === 409,
|
||||
`тестовый пользователь ${PROBE_LOGIN} не зарегистрирован: ${await registered.text()}`,
|
||||
).toBeTruthy();
|
||||
// 409 — аккаунт уже существует (например, гонка прогонов): просто входим.
|
||||
login = await postWithRetry(guest, '/api/v1/auth/login', credentials);
|
||||
}
|
||||
expect(login.ok(), `вход тестового пользователя: ${await login.text()}`).toBeTruthy();
|
||||
const payload = (await login.json()) as { user: { id: string } };
|
||||
await finishOnboarding(guest);
|
||||
return { id: payload.user.id, email, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* prepare создаёт сервер и голосовую комнату, приглашает постоянного тестового
|
||||
* пользователя. `admin` остаётся сессией администратора (модерация и уборка),
|
||||
* `guest` — сессией приглашённого пользователя.
|
||||
*/
|
||||
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
||||
const adminUser = await loginAsAdmin(admin);
|
||||
await finishOnboarding(admin);
|
||||
await dropStaleGuilds(admin);
|
||||
async function prepare(adminApi: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
|
||||
const adminUser = await loginAsAdmin(adminApi);
|
||||
await finishOnboarding(adminApi);
|
||||
await dropStaleGuilds(adminApi, 'Voice E2E ');
|
||||
|
||||
const stamp = Date.now();
|
||||
const guildName = `Voice E2E ${stamp}`;
|
||||
const guild = await admin.post('/api/v1/guilds', { data: { name: guildName } });
|
||||
const guild = await adminApi.post('/api/v1/guilds', { data: { name: guildName } });
|
||||
expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy();
|
||||
const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id;
|
||||
|
||||
const channel = await admin.post(`/api/v1/guilds/${guildId}/channels`, {
|
||||
const channel = await adminApi.post(`/api/v1/guilds/${guildId}/channels`, {
|
||||
data: { name: 'Голос', type: 'voice' },
|
||||
});
|
||||
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
|
||||
const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id;
|
||||
|
||||
const invite = await admin.post(`/api/v1/guilds/${guildId}/invites`, {
|
||||
const invite = await adminApi.post(`/api/v1/guilds/${guildId}/invites`, {
|
||||
data: { max_uses: 1, max_age_seconds: 600 },
|
||||
});
|
||||
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
||||
const code = ((await invite.json()) as { invite: { code: string } }).invite.code;
|
||||
|
||||
const probe = await ensureProbeGuest(guest);
|
||||
const probe = await ensureProbe(guest, PROBE_LOGIN, 'Voice-Probe-Password-9x');
|
||||
const accept = await guest.post(`/api/v1/invites/${code}`);
|
||||
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
||||
|
||||
@@ -256,35 +120,18 @@ async function inboundAudioBytes(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
/** signIn логинит браузерный контекст и открывает приложение. */
|
||||
async function signIn(
|
||||
/**
|
||||
* signInVoice логинит браузерный контекст с фейковыми медиа-устройствами и
|
||||
* патчем RTCPeerConnection: так проверяется реальная доставка аудио.
|
||||
*/
|
||||
async function signInVoice(
|
||||
browser: Browser,
|
||||
options: { email: string; password: string; totp?: string },
|
||||
): Promise<{ context: BrowserContext; page: Page }> {
|
||||
const context = await browser.newContext({ permissions: ['microphone'] });
|
||||
await instrumentPeerConnections(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);
|
||||
// Лимит частоты входов (5/мин) может сработать на серии прогонов: тогда
|
||||
// форма показывает ошибку — ждём и пробуем снова со свежим кодом 2FA.
|
||||
for (let attempt = 1; attempt <= 3; 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: 20_000 });
|
||||
return { context, page };
|
||||
} catch (error) {
|
||||
if (attempt === 3) {
|
||||
throw error;
|
||||
}
|
||||
await page.waitForTimeout(6_000);
|
||||
}
|
||||
}
|
||||
throw new Error('вход не удался');
|
||||
return signIn(browser, options, {
|
||||
contextOptions: { permissions: ['microphone'] },
|
||||
onContext: instrumentPeerConnections,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,28 +149,28 @@ async function joinVoice(page: Page): Promise<void> {
|
||||
|
||||
test.describe('голос: два клиента в одной комнате', () => {
|
||||
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1');
|
||||
test.skip(ADMIN_EMAIL === '' || ADMIN_PASSWORD === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP');
|
||||
test.skip(admin.email === '' || admin.password === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP');
|
||||
|
||||
test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => {
|
||||
const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||||
const adminApi = await playwright.request.newContext({ baseURL });
|
||||
const guestApi = await playwright.request.newContext({ baseURL });
|
||||
const fixture = await prepare(adminApi, guestApi);
|
||||
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
let guest: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
let adminBrowser: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
let guestBrowser: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||
|
||||
try {
|
||||
admin = await signIn(browser, {
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
totp: ADMIN_TOTP,
|
||||
adminBrowser = await signInVoice(browser, {
|
||||
email: admin.email,
|
||||
password: admin.password,
|
||||
totp: admin.totp,
|
||||
});
|
||||
guest = await signIn(browser, {
|
||||
guestBrowser = await signInVoice(browser, {
|
||||
email: fixture.guestEmail,
|
||||
password: fixture.guestPassword,
|
||||
});
|
||||
const adminSession = admin;
|
||||
const guestSession = guest;
|
||||
const adminSession = adminBrowser;
|
||||
const guestSession = guestBrowser;
|
||||
|
||||
// Оба клиента открывают голосовую комнату и подключаются.
|
||||
for (const session of [adminSession, guestSession]) {
|
||||
@@ -398,8 +245,8 @@ test.describe('голос: два клиента в одной комнате',
|
||||
} finally {
|
||||
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
|
||||
await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
|
||||
await admin?.context.close();
|
||||
await guest?.context.close();
|
||||
await adminBrowser?.context.close();
|
||||
await guestBrowser?.context.close();
|
||||
await adminApi.dispose();
|
||||
await guestApi.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user