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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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-списке
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user