fix(voice): административный токен RoomService привязан к комнате

LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).

- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
  пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
  (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
  одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
  кнопки.
This commit is contained in:
2026-09-20 19:54:21 +03:00
parent 912a067c9a
commit a6d23a295e
4 changed files with 108 additions and 45 deletions
+53 -25
View File
@@ -99,29 +99,44 @@ async function finishOnboarding(api: APIRequestContext): Promise<void> {
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
}
/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */
async function prepare(api: APIRequestContext): Promise<Fixture> {
const adminLogin = await api.post('/api/v1/auth/login', {
/**
* loginAsAdmin логинит api-контекст под инстанс-администратором и возвращает
* его профиль. Код 2FA одноразовый в пределах окна, поэтому администратор
* логинится ровно один раз — отдельным контекстом, который гостевые действия
* не переключают на другого пользователя.
*/
async function loginAsAdmin(api: APIRequestContext): Promise<{ id: string }> {
const response = await api.post('/api/v1/auth/login', {
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
});
expect(adminLogin.ok(), `вход администратора: ${await adminLogin.text()}`).toBeTruthy();
const admin = (await adminLogin.json()) as { user: { id: string } };
await finishOnboarding(api);
await dropStaleGuilds(api);
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
const payload = (await response.json()) as { user: { id: string } };
return payload.user;
}
/**
* prepare создаёт сервер, голосовую комнату и второго пользователя по
* приглашению. `admin` остаётся сессией администратора (модерация и уборка),
* `guest` — сессией приглашённого пользователя.
*/
async function prepare(admin: APIRequestContext, guest: APIRequestContext): Promise<Fixture> {
const adminUser = await loginAsAdmin(admin);
await finishOnboarding(admin);
await dropStaleGuilds(admin);
const stamp = Date.now();
const guildName = `Voice E2E ${stamp}`;
const guild = await api.post('/api/v1/guilds', { data: { name: guildName } });
const guild = await admin.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 api.post(`/api/v1/guilds/${guildId}/channels`, {
const channel = await admin.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 api.post(`/api/v1/guilds/${guildId}/invites`, {
const invite = await admin.post(`/api/v1/guilds/${guildId}/invites`, {
data: { max_uses: 1, max_age_seconds: 600 },
});
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
@@ -130,7 +145,7 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
const guestLogin = `voice${stamp}`;
const guestEmail = `${guestLogin}@gl.mhspx.su`;
const guestPassword = 'Voice-Probe-Password-9x';
const guestContext = await api.post('/api/v1/auth/register', {
const guestContext = await guest.post('/api/v1/auth/register', {
data: {
username: guestLogin,
email: guestEmail,
@@ -139,10 +154,9 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
});
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
// Регистрация переключила сессию api-контекста на гостя — доводим его до /app.
await finishOnboarding(api);
await finishOnboarding(guest);
const accept = await api.post(`/api/v1/invites/${code}`);
const accept = await guest.post(`/api/v1/invites/${code}`);
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
return {
@@ -151,7 +165,7 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
channelId,
guestEmail,
guestPassword,
adminId: admin.user.id,
adminId: adminUser.id,
guestId,
};
}
@@ -211,15 +225,28 @@ async function signIn(
return { context, page };
}
/**
* joinVoice нажимает «Войти» в голосовой комнате. Кнопка исчезает в тот же
* момент, когда приходит состояние «Подключено», поэтому обычный click может
* зависнуть на проверке стабильности элемента: жмём без проверок и дожидаемся
* фактического результата — кнопки входа больше нет.
*/
async function joinVoice(page: Page): Promise<void> {
const button = page.getByTestId('voice-join');
await button.waitFor({ state: 'visible', timeout: 20_000 });
await button.click({ force: true, timeout: 10_000 }).catch(() => undefined);
await expect(button).toBeHidden({ timeout: 30_000 });
}
test.describe('голос: два клиента в одной комнате', () => {
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1');
test.skip(ADMIN_EMAIL === '' || ADMIN_PASSWORD === '', 'нужны GLCHAT_ADMIN_EMAIL/PASSWORD/TOTP');
test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => {
const api = await playwright.request.newContext({
baseURL: process.env.GLCHAT_URL ?? 'https://gl.mhspx.su',
});
const fixture = await prepare(api);
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;
@@ -242,11 +269,11 @@ test.describe('голос: два клиента в одной комнате',
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
.click();
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click();
await session.page.getByTestId('voice-join').click();
await joinVoice(session.page);
}
// Каждый видит обоих участников.
for (const session of [admin, guest]) {
for (const session of [adminSession, guestSession]) {
await expect(session.page.getByTestId(`voice-tile-${fixture.adminId}`)).toBeVisible();
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
}
@@ -259,7 +286,7 @@ test.describe('голос: два клиента в одной комнате',
}
// Серверный мьют гостя применяется на стороне SFU.
const mute = await api.patch(
const mute = await adminApi.patch(
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
{
data: { server_mute: true },
@@ -274,7 +301,7 @@ test.describe('голос: два клиента в одной комнате',
await expect(guestMic).toBeDisabled({ timeout: 30_000 });
// Отключение участника от голосовой комнаты убирает его из списка.
const drop = await api.delete(
const drop = await adminApi.delete(
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
);
expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy();
@@ -286,10 +313,11 @@ test.describe('голос: два клиента в одной комнате',
});
} finally {
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
await api.delete(`/api/v1/guilds/${fixture.guildId}`);
await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
await admin?.context.close();
await guest?.context.close();
await api.dispose();
await adminApi.dispose();
await guestApi.dispose();
}
});
});