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:
2026-09-22 19:14:05 +03:00
parent b403f6c894
commit 785af0dc7b
16 changed files with 1569 additions and 219 deletions
+300
View File
@@ -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
View File
@@ -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();
}
+42 -1
View File
@@ -1,6 +1,6 @@
import { parseGuildEmojiList } from './emojis';
import { parseGuildSoundList } from './sounds';
import { userStatuses, type Channel, type UserStatus } from './types';
import { userStatuses, type Channel, type ChannelOverwrite, type UserStatus } from './types';
import type { GatewayUser } from './gateway';
import type { GuildEmoji } from './emojis';
import type { GuildSound } from './sounds';
@@ -284,6 +284,42 @@ export function parseSoundboardPlay(value: unknown): SoundboardPlayEvent | null
}
/** Канал из `CHANNEL_CREATE`/`CHANNEL_UPDATE` — та же форма, что в REST. */
/** `GUILD_CHANNELS_SYNC`: права комнат сервера изменились — перечитать список. */
export function parseGuildChannelsSyncEvent(value: unknown): { guild_id: string } | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const guildId = asString(record['guild_id']);
return guildId === undefined ? null : { guild_id: guildId };
}
/** parseChannelOverwrites читает список оверрайдов прав комнаты. */
export function parseChannelOverwrites(value: unknown): ChannelOverwrite[] {
if (!Array.isArray(value)) {
return [];
}
const result: ChannelOverwrite[] = [];
for (const item of value) {
const record = asRecord(item);
if (record === null) {
continue;
}
const targetType = asString(record['target_type']);
const targetId = asString(record['target_id']);
if ((targetType !== 'role' && targetType !== 'user') || targetId === undefined) {
continue;
}
result.push({
target_type: targetType,
target_id: targetId,
allow: asString(record['allow']) ?? '0',
deny: asString(record['deny']) ?? '0',
});
}
return result;
}
export function parseChannelPayload(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
@@ -327,6 +363,11 @@ export function parseChannelPayload(value: unknown): Channel | null {
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
// Права комнаты: нужны редактору доступа (AGENT.md 6.2).
const overwrites = parseChannelOverwrites(record['permission_overwrites']);
if (overwrites.length > 0) {
channel.permission_overwrites = overwrites;
}
// Фон комнаты: приходит в READY и CHANNEL_UPDATE (AGENT.md 7.5).
const backgroundFileId = asString(record['background_file_id']);
if (backgroundFileId !== undefined) {
+39
View File
@@ -120,6 +120,45 @@ export async function updateRole(
return payload.role;
}
/** Права комнаты в масках (AGENT.md 6.2): тем же способом, что права роли. */
export interface ChannelOverwriteInput {
allow: string;
deny: string;
step_up_password?: string;
step_up_totp?: string;
}
/**
* `PUT /guilds/{id}/channels/{cid}/overwrites/{type}/{tid}` — права комнаты
* для роли или участника поверх серверных.
*/
export async function setChannelOverwrite(
guildId: string,
channelId: string,
targetType: 'role' | 'user',
targetId: string,
body: ChannelOverwriteInput,
): Promise<Channel> {
const payload = await request<{ channel: Channel }>(
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
{ method: 'PUT', body },
);
return payload.channel;
}
/** `DELETE .../overwrites/{type}/{tid}` — снять права комнаты (нужен step-up). */
export function deleteChannelOverwrite(
guildId: string,
channelId: string,
targetType: 'role' | 'user',
targetId: string,
): Promise<{ ok: true }> {
return request<{ ok: true }>(
`/guilds/${encodeURIComponent(guildId)}/channels/${encodeURIComponent(channelId)}/overwrites/${targetType}/${encodeURIComponent(targetId)}`,
{ method: 'DELETE' },
);
}
export function joinGuild(guildId: string): Promise<{ ok: true }> {
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
}
+10
View File
@@ -75,6 +75,14 @@ export interface Role {
nick_effect?: string;
}
/** Оверрайд прав комнаты для роли или участника (AGENT.md 6.2). */
export interface ChannelOverwrite {
target_type: 'role' | 'user';
target_id: string;
allow: string;
deny: string;
}
export interface Channel {
id: string;
/** Фон комнаты: файл-картинка за лентой сообщений (AGENT.md 7.5). */
@@ -89,6 +97,8 @@ export interface Channel {
can_view?: boolean;
can_send?: boolean;
can_connect?: boolean;
/** Права комнаты поверх серверных (AGENT.md 6.2): маски приходят строками. */
permission_overwrites?: ChannelOverwrite[];
/**
* Поля личной беседы (`type: 'dm'`): id собеседника, его аватар и видимый
* статус. В снапшоте READY аватар приходит как `icon_file_id`, в REST-списке
+13 -1
View File
@@ -1182,7 +1182,19 @@
"upload": "Upload background",
"remove": "Remove background",
"notImage": "An image file is required",
"tooLarge": "File is larger than 5 MB"
"tooLarge": "File is larger than 5 MB",
"permissions": {
"title": "Channel access",
"description": "Channel permissions on top of server ones: a private channel is hidden from @everyone, access is granted to roles and individual members.",
"private": "Private channel (hide from @everyone)",
"everyone": "@everyone",
"view": "View",
"send": "Send",
"connect": "Connect",
"inherit": "Server default",
"allow": "Allow",
"deny": "Deny"
}
}
},
"badges": {
+13 -1
View File
@@ -1182,7 +1182,19 @@
"upload": "Загрузить фон",
"remove": "Убрать фон",
"notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ"
"tooLarge": "Файл больше 5 МБ",
"permissions": {
"title": "Доступ к комнате",
"description": "Права комнаты поверх серверных: приватная комната скрыта для @everyone, доступ выдают роли и отдельные участники.",
"private": "Приватная комната (скрыть от @everyone)",
"everyone": "@everyone",
"view": "Просмотр",
"send": "Писать",
"connect": "Вход",
"inherit": "Как на сервере",
"allow": "Разрешено",
"deny": "Запрещено"
}
}
},
"badges": {
@@ -0,0 +1,316 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
deleteChannelOverwrite,
fetchMembers,
fetchRoles,
guildChannelsQueryKey,
guildMembersQueryKey,
guildRolesQueryKey,
setChannelOverwrite,
} from '@/api/guilds';
import type { Channel, ChannelOverwrite, GuildMember, Role } from '@/api/types';
import { stepUp } from '@/api/auth';
import { Button, Card } from '@/components/ui/primitives';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { errorCode } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
/** Имена прав, которыми управляет редактор доступа (AGENT.md 6.2). */
const VIEW_CHANNEL = 'VIEW_CHANNEL';
const SEND_MESSAGES = 'SEND_MESSAGES';
const CONNECT_VOICE = 'CONNECT_VOICE';
/** Три состояния права: как на сервере, разрешено, запрещено. */
type TriState = 'inherit' | 'allow' | 'deny';
/** Действие над оверрайдом: задать права или снять цель целиком. */
type PendingAction =
| { kind: 'set'; targetType: 'role' | 'user'; targetId: string; allow: string; deny: string }
| { kind: 'delete'; targetType: 'role' | 'user'; targetId: string };
/** names разбирает строку прав «A|B» в список, отбрасывая пустые значения. */
function names(value: string | undefined): string[] {
return (value ?? '').split('|').filter((item) => item !== '');
}
interface ChannelPermissionsSectionProps {
guildId: string | null;
channel: Channel;
}
/** stateOf читает состояние одного права из оверрайда. */
function stateOf(overwrite: ChannelOverwrite | undefined, name: string): TriState {
if (overwrite === undefined) {
return 'inherit';
}
if (names(overwrite.allow).includes(name)) {
return 'allow';
}
if (names(overwrite.deny).includes(name)) {
return 'deny';
}
return 'inherit';
}
/** applyState меняет одно право, сохраняя остальные (AGENT.md 6.2). */
function applyState(
overwrite: ChannelOverwrite | undefined,
name: string,
state: TriState,
): { allow: string; deny: string } {
const allow = names(overwrite?.allow).filter((item) => item !== name);
const deny = names(overwrite?.deny).filter((item) => item !== name);
if (state === 'allow') {
allow.push(name);
}
if (state === 'deny') {
deny.push(name);
}
return { allow: allow.join('|'), deny: deny.join('|') };
}
/**
* Редактор доступа к комнате (AGENT.md 6.2, 7.5): приватность для @everyone
* плюс права ролей и участников по каждому из ключевых прав. Изменение прав
* требует подтверждения личности (AGENT.md 9.3) — сервер отвечает
* `auth.step_up_required`, после чего действие повторяется со step-up.
*/
export function ChannelPermissionsSection({ guildId, channel }: ChannelPermissionsSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [actionError, setActionError] = useState<unknown>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [stepUpPassword, setStepUpPassword] = useState('');
const [stepUpCode, setStepUpCode] = useState('');
const roles = useQuery({
queryKey: guildRolesQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchRoles(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const members = useQuery({
queryKey: guildMembersQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchMembers(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const overwrites = useMemo(() => channel.permission_overwrites ?? [], [channel]);
const defaultRole = useMemo(
() => (roles.data ?? []).find((role) => role.is_default) ?? null,
[roles.data],
);
const overwriteFor = (
targetType: 'role' | 'user',
targetId: string,
): ChannelOverwrite | undefined =>
overwrites.find((item) => item.target_type === targetType && item.target_id === targetId);
const isPrivate =
stateOf(
defaultRole === null ? undefined : overwriteFor('role', defaultRole.id),
VIEW_CHANNEL,
) === 'deny';
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
};
const action = useMutation({
mutationFn: async (input: PendingAction) => {
if (guildId === null) {
return;
}
if (input.kind === 'set') {
await setChannelOverwrite(guildId, channel.id, input.targetType, input.targetId, {
allow: input.allow,
deny: input.deny,
});
return;
}
await deleteChannelOverwrite(guildId, channel.id, input.targetType, input.targetId);
},
onSuccess: () => {
setActionError(null);
setPendingAction(null);
setStepUpPassword('');
setStepUpCode('');
invalidate();
},
onError: (cause: unknown, variables) => {
setActionError(cause);
// Сервер требует свежее подтверждение личности (AGENT.md 9.3): показываем
// форму и повторяем то же действие после успешного step-up.
if (errorCode(cause) === 'auth.step_up_required') {
setPendingAction(variables);
}
},
});
const confirmIdentity = useMutation({
mutationFn: () => stepUp(stepUpPassword, stepUpCode === '' ? undefined : stepUpCode),
onSuccess: () => {
const retry = pendingAction;
setStepUpPassword('');
setStepUpCode('');
if (retry !== null) {
action.mutate(retry);
}
},
});
if (guildId === null) {
return null;
}
const permissions: { name: string; label: string }[] =
channel.type === 'voice'
? [
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
{ name: CONNECT_VOICE, label: t('settings.channel.permissions.connect') },
]
: [
{ name: VIEW_CHANNEL, label: t('settings.channel.permissions.view') },
{ name: SEND_MESSAGES, label: t('settings.channel.permissions.send') },
];
const rows: { key: string; title: string; targetType: 'role' | 'user'; targetId: string }[] = [
...(roles.data ?? []).map((role: Role) => ({
key: `role-${role.id}`,
title: role.is_default ? t('settings.channel.permissions.everyone') : role.name,
targetType: 'role' as const,
targetId: role.id,
})),
...(members.data ?? []).map((member: GuildMember) => ({
key: `user-${member.user_id}`,
title: `${member.display_name} @${member.username ?? ''}`,
targetType: 'user' as const,
targetId: member.user_id,
})),
];
const togglePrivate = (next: boolean): void => {
if (defaultRole === null) {
return;
}
const existing = overwriteFor('role', defaultRole.id);
if (!next) {
// Снимаем оверрайд @everyone целиком: комната снова как на сервере.
action.mutate({ kind: 'delete', targetType: 'role', targetId: defaultRole.id });
return;
}
const masks = applyState(existing, VIEW_CHANNEL, 'deny');
action.mutate({ kind: 'set', targetType: 'role', targetId: defaultRole.id, ...masks });
};
const pending = action.isPending || confirmIdentity.isPending;
return (
<Card>
<h3 className="text-base font-semibold">{t('settings.channel.permissions.title')}</h3>
<p className="mt-1 text-sm text-fg-muted">{t('settings.channel.permissions.description')}</p>
{actionError === null ? null : <ErrorNotice className="mt-3" error={actionError} />}
{pendingAction === null ? null : (
<form
className="mt-3 flex flex-wrap items-end gap-2 rounded-[var(--radius-md)] border border-warning/50 bg-warning/10 p-2"
data-testid="channel-permissions-step-up"
onSubmit={(event) => {
event.preventDefault();
confirmIdentity.mutate();
}}
>
<Field
label={t('settings.security.stepUpPassword')}
type="password"
name="step_up_password"
autoComplete="current-password"
value={stepUpPassword}
onChange={(event) => setStepUpPassword(event.target.value)}
required
/>
{currentUser.data?.totp_enabled === true ? (
<Field
label={t('auth.login.totp')}
name="step_up_totp"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={8}
value={stepUpCode}
onChange={(event) => setStepUpCode(event.target.value)}
/>
) : null}
{confirmIdentity.isError ? <ErrorNotice error={confirmIdentity.error} /> : null}
<Button type="submit" disabled={confirmIdentity.isPending}>
{t('settings.security.stepUpSubmit')}
</Button>
<Button variant="ghost" onClick={() => setPendingAction(null)}>
{t('common.cancel')}
</Button>
</form>
)}
<label className="mt-3 flex items-center gap-2 text-sm">
<input
type="checkbox"
data-testid="channel-private-toggle"
checked={isPrivate}
disabled={pending || defaultRole === null}
onChange={(event) => togglePrivate(event.target.checked)}
/>
{t('settings.channel.permissions.private')}
</label>
<div className="mt-4 flex flex-col gap-2" data-testid="channel-permissions">
{rows.map((row) => {
const overwrite = overwriteFor(row.targetType, row.targetId);
const canEdit = !(row.targetType === 'user' && row.targetId === currentUser.data?.id);
return (
<div
key={row.key}
className="flex flex-wrap items-center gap-3 border-b border-border/40 py-2 last:border-b-0"
data-testid={`channel-permission-row-${row.targetId}`}
>
<span className="min-w-0 flex-1 truncate">{row.title}</span>
{permissions.map((permission) => (
<label key={permission.label} className="flex items-center gap-1 text-xs">
<span className="text-fg-muted">{permission.label}</span>
<select
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5"
data-testid={`channel-permission-${row.targetId}-${permission.name.toLowerCase()}`}
value={stateOf(overwrite, permission.name)}
disabled={pending || !canEdit}
onChange={(event) => {
const masks = applyState(
overwrite,
permission.name,
event.target.value as TriState,
);
action.mutate({
kind: 'set',
targetType: row.targetType,
targetId: row.targetId,
...masks,
});
}}
>
<option value="inherit">{t('settings.channel.permissions.inherit')}</option>
<option value="allow">{t('settings.channel.permissions.allow')}</option>
<option value="deny">{t('settings.channel.permissions.deny')}</option>
</select>
</label>
))}
</div>
);
})}
</div>
</Card>
);
}
+37 -16
View File
@@ -13,6 +13,7 @@ import { canManageWebhooks } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelBackgroundSection } from '@/pages/settings/ChannelBackgroundSection';
import { ChannelPermissionsSection } from '@/pages/settings/ChannelPermissionsSection';
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
@@ -80,21 +81,26 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
retry: 0,
});
const textChannels = useMemo(
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
// В списке и текстовые, и голосовые комнаты: у голосовых настраиваются фон и
// права доступа (AGENT.md 7.5), вебхуки — только у текстовых.
const selectableChannels = useMemo(
() =>
(channels.data ?? []).filter(
(channel: Channel) => channel.type === 'text' || channel.type === 'voice',
),
[channels.data],
);
// Первую текстовую комнату выбираем сами: список только что загрузился.
// Первую комнату выбираем сами: список только что загрузился.
useEffect(() => {
if (settingsChannelId !== null || textChannels.length === 0) {
if (settingsChannelId !== null || selectableChannels.length === 0) {
return;
}
const first = textChannels[0];
const first = selectableChannels[0];
if (first !== undefined) {
selectSettingsChannel(first.id);
}
}, [settingsChannelId, textChannels, selectSettingsChannel]);
}, [settingsChannelId, selectableChannels, selectSettingsChannel]);
if (guildId === null || guild === null) {
return (
@@ -111,13 +117,19 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
currentUser.data?.id,
isInstanceAdmin,
);
// Права комнаты меняет тот, кто управляет ролями (AGENT.md 6.2).
const canManagePermissions =
isInstanceAdmin ||
guild.owner_id === currentUser.data?.id ||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
(guild.my_permissions ?? []).includes('MANAGE_ROLES');
// Фон комнаты — отдельное право MANAGE_CHANNEL_BACKGROUND (AGENT.md 7.5).
const canManageBackground =
isInstanceAdmin ||
guild.owner_id === currentUser.data?.id ||
(guild.my_permissions ?? []).includes('ADMINISTRATOR') ||
(guild.my_permissions ?? []).includes('MANAGE_CHANNEL_BACKGROUND');
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
const active = selectableChannels.find((channel) => channel.id === settingsChannelId) ?? null;
return (
<>
@@ -131,21 +143,21 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
</Card>
) : null}
{channels.isSuccess && textChannels.length === 0 ? (
{channels.isSuccess && selectableChannels.length === 0 ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
</Card>
) : null}
{textChannels.length === 0 ? null : (
{selectableChannels.length === 0 ? null : (
<Card>
<SelectField
label={t('settings.channel.pick')}
value={active?.id ?? ''}
onChange={(value) => selectSettingsChannel(value)}
options={textChannels.map((channel) => ({
options={selectableChannels.map((channel) => ({
value: channel.id,
label: `#${channel.name}`,
label: `${channel.type === 'voice' ? '🔊' : '#'}${channel.name}`,
}))}
/>
</Card>
@@ -159,11 +171,20 @@ function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
backgroundFileId={active.background_file_id}
canManage={canManageBackground}
/>
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
{active.type === 'text' ? (
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
) : null}
{canManagePermissions ? (
<ChannelPermissionsSection
key={`${active.id}-permissions`}
guildId={guildId}
channel={active}
/>
) : null}
{canManage ? null : (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
+10
View File
@@ -11,6 +11,7 @@ import {
} from '@/api/gateway';
import {
parseChannelDelete,
parseGuildChannelsSyncEvent,
parseChannelPayload,
parseDmChannelEvent,
parseGuildEmojisUpdate,
@@ -334,6 +335,15 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
}
return;
}
case 'GUILD_CHANNELS_SYNC': {
// Права комнаты изменились: видимость может поменяться у всех
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
const sync = parseGuildChannelsSyncEvent(event.d);
if (sync !== null) {
void session.reloadGuildChannels(sync.guild_id);
}
return;
}
case 'DM_CHANNEL_CREATE': {
// В событии только channel_id: список бесед забираем по REST.
const direct = parseDmChannelEvent(event.d);
+12
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { GuildEmoji } from '@/api/emojis';
import { fetchChannels } from '@/api/guilds';
import { fetchDirectChannels } from '@/api/friends';
import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
import type { GuildSound, SoundKind } from '@/api/sounds';
@@ -113,6 +114,8 @@ interface SessionState {
reloadDmChannels: () => Promise<void>;
/** CHANNEL_DELETE: удаляет комнату и переносит выбор на первую доступную. */
removeChannel: (guildId: string, channelId: string) => void;
/** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */
reloadGuildChannels: (guildId: string) => Promise<void>;
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
firstChannelId: (guildId: string) => string | null;
selectGuild: (guildId: string | null) => void;
@@ -351,6 +354,15 @@ export const useSessionStore = create<SessionState>((set, get) => ({
});
},
reloadGuildChannels: async (guildId) => {
try {
const channels = await fetchChannels(guildId);
get().setGuildChannels(guildId, channels);
} catch {
// Сеть подвела: список обновится при следующем изменении прав.
}
},
setGuildChannels: (guildId, channels) => {
set({
guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)),
+216
View File
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
messagesRoutes,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Доступ к комнате (AGENT.md 6.2, 7.5): приватная комната закрывается запретом
* @everyone, права выдаются ролям, а смена прав требует подтверждения личности.
*/
const user = makeUser({ id: 'user-1' });
const channel = {
id: 'c-1',
guild_id: 'g-1',
name: 'общий',
type: 'text' as const,
position: 0,
can_view: true,
permission_overwrites: [],
};
const roles = [
{ id: 'r-1', name: '@everyone', color: 0, position: 0, permissions: '', is_default: true },
{ id: 'r-2', name: 'Модераторы', color: 0, position: 1, permissions: '', is_default: false },
];
const members = [
{
user_id: 'user-2',
username: 'bob',
display_name: 'Bob',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-01T00:00:00Z',
role_ids: [],
},
];
/** routes собирает мок REST: комнаты, роли и участники сервера. */
function routes(permissions: string[], extra: FetchRoute[] = []): FetchRoute[] {
return [
...extra,
{
match: '/api/v1/users/@me/guilds',
response: () =>
json({
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 2,
my_permissions: permissions,
my_role_ids: [],
},
],
}),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [channel] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles }) },
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 2,
my_role_ids: [],
my_permissions: permissions,
channels: [channel],
roles: [],
},
}),
},
...messagesRoutes('c-1', []),
];
}
beforeEach(() => {
resetStores();
});
/** openChannelSettings открывает настройки комнаты из шапки чата. */
async function openChannelSettings(permissions: string[], extra: FetchRoute[] = []) {
const fetchMock = installFetch(routes(permissions, extra));
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
owner_id: permissions.includes('MANAGE_ROLES') ? 'user-1' : 'user-9',
my_permissions: permissions,
channels: [channel],
},
]);
const gateway = installGatewaySocket(snapshot);
renderApp('/app/g-1/c-1');
await gateway.greet();
await screen.findByTestId('message-list');
await userEvent.click(await screen.findByTestId('channel-settings'));
const content = await screen.findByTestId('settings-content');
return { fetchMock, content };
}
describe('доступ к комнате', () => {
it('приватная комната закрывается запретом @everyone, права ролей меняются', async () => {
const calls: { url: string; body: unknown }[] = [];
const { content } = await openChannelSettings(
['MANAGE_ROLES', 'MANAGE_GUILD'],
[
{
match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-1',
method: 'PUT',
response: (request) => {
calls.push({ url: request.url, body: request.body });
return json({ channel });
},
},
{
match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-2',
method: 'PUT',
response: (request) => {
calls.push({ url: request.url, body: request.body });
return json({ channel });
},
},
],
);
const editor = await within(content).findByTestId('channel-permissions');
expect(within(editor).getByTestId('channel-permission-row-r-1')).toHaveTextContent('@everyone');
// Включаем приватность: оверрайд @everyone получает запрет просмотра.
await userEvent.click(within(content).getByTestId('channel-private-toggle'));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]?.body).toEqual({ allow: '', deny: 'VIEW_CHANNEL' });
// Разрешаем роли просмотр: в запросе появляется allow с именем права.
await userEvent.selectOptions(
within(editor).getByTestId('channel-permission-r-2-view_channel'),
'allow',
);
await waitFor(() => expect(calls).toHaveLength(2));
expect(calls[1]?.url).toContain('/overwrites/role/r-2');
expect(calls[1]?.body).toEqual({ allow: 'VIEW_CHANNEL', deny: '' });
// Запрет переписки для @everyone уходит отдельным правом.
await userEvent.selectOptions(
within(editor).getByTestId('channel-permission-r-1-send_messages'),
'deny',
);
await waitFor(() => expect(calls).toHaveLength(3));
expect(calls[2]?.body).toEqual({ allow: '', deny: 'SEND_MESSAGES' });
});
it('смена прав повторяется после подтверждения личности', async () => {
const calls: { body: unknown }[] = [];
let requireStepUp = true;
const { content } = await openChannelSettings(
['MANAGE_ROLES', 'MANAGE_GUILD'],
[
{
match: '/api/v1/guilds/g-1/channels/c-1/overwrites/role/r-1',
method: 'PUT',
response: (request) => {
calls.push({ body: request.body });
// Сервер требует свежее подтверждение личности (AGENT.md 9.3).
if (requireStepUp) {
requireStepUp = false;
return json({ error: { code: 'auth.step_up_required', message: 'need' } }, 403);
}
return json({ channel });
},
},
{ match: '/api/v1/auth/step-up', method: 'POST', response: () => json({ ok: true }) },
],
);
await within(content).findByTestId('channel-permissions');
await userEvent.click(within(content).getByTestId('channel-private-toggle'));
await waitFor(() => expect(calls).toHaveLength(1));
const stepUp = await within(content).findByTestId('channel-permissions-step-up');
await userEvent.type(within(stepUp).getByLabelText('Ваш пароль'), 'correct-horse-battery');
await userEvent.click(within(stepUp).getByRole('button', { name: 'Подтвердить' }));
await waitFor(() => expect(calls).toHaveLength(2));
expect(calls[1]?.body).toEqual({ allow: '', deny: 'VIEW_CHANNEL' });
});
it('без права MANAGE_ROLES редактор доступа скрыт', async () => {
// Шестерёнка комнаты доступна по MANAGE_WEBHOOKS, но прав на доступ нет.
const { content } = await openChannelSettings(['MANAGE_WEBHOOKS', 'MANAGE_GUILD']);
await waitFor(() =>
expect(within(content).queryByTestId('channel-permissions')).not.toBeInTheDocument(),
);
});
});