diff --git a/internal/voice/admin.go b/internal/voice/admin.go index 7066c05..f522b10 100644 --- a/internal/voice/admin.go +++ b/internal/voice/admin.go @@ -53,7 +53,7 @@ func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string var response struct { Participant Participant `json:"participant"` } - err := c.call(ctx, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response) + err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response) if err != nil { return nil, err } @@ -78,14 +78,14 @@ func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity str // Участник без микрофона: мьютить нечего, это не ошибка. return nil } - return c.call(ctx, "MutePublishedTrack", map[string]any{ + return c.call(ctx, room, "MutePublishedTrack", map[string]any{ "room": room, "identity": identity, "track_sid": trackSID, "muted": muted, }, nil) } // RemoveParticipant удаляет участника из комнаты (кик из голосовой). func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error { - return c.call(ctx, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil) + return c.call(ctx, room, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil) } // ListParticipants перечисляет участников комнаты: используется для проверки @@ -94,14 +94,15 @@ func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Part var response struct { Participants []Participant `json:"participants"` } - if err := c.call(ctx, "ListParticipants", map[string]any{"room": room}, &response); err != nil { + if err := c.call(ctx, room, "ListParticipants", map[string]any{"room": room}, &response); err != nil { return nil, err } return response.Participants, nil } -// call выполняет запрос к Twirp-методу RoomService с административным токеном. -func (c *AdminClient) call(ctx context.Context, method string, body any, out any) error { +// call выполняет запрос к Twirp-методу RoomService с административным токеном +// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты. +func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error { if !c.Enabled() { return ErrDisabled } @@ -109,7 +110,7 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any if err != nil { return err } - token, err := c.adminToken() + token, err := c.issuer.IssueAdmin(room) if err != nil { return err } @@ -141,8 +142,3 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any } return nil } - -// adminToken подписывает административный токен RoomService. -func (c *AdminClient) adminToken() (string, error) { - return c.issuer.IssueAdmin() -} diff --git a/internal/voice/token.go b/internal/voice/token.go index 168a947..64684ec 100644 --- a/internal/voice/token.go +++ b/internal/voice/token.go @@ -120,12 +120,17 @@ func encodeSegment(payload []byte) string { return base64.RawURLEncoding.EncodeToString(payload) } -// IssueAdmin подписывает административный токен RoomService: полные права на -// комнаты (модерация, удаление участников), но не на запись медиа. -func (i *TokenIssuer) IssueAdmin() (string, error) { +// IssueAdmin подписывает административный токен RoomService: права на +// модерацию одной комнаты (мьют дорожки, удаление участника), но не на запись +// медиа. LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room` в +// грахте RoomService отвечает 401 `permissions denied`. +func (i *TokenIssuer) IssueAdmin(room string) (string, error) { if !i.Enabled() { return "", ErrDisabled } + if strings.TrimSpace(room) == "" { + return "", fmt.Errorf("voice.invalid_room: пустое имя комнаты") + } now := i.now().UTC() claims := map[string]any{ "iss": i.apiKey, @@ -133,11 +138,8 @@ func (i *TokenIssuer) IssueAdmin() (string, error) { "nbf": now.Add(-10 * time.Second).Unix(), "exp": now.Add(10 * time.Minute).Unix(), "video": map[string]any{ - "roomCreate": true, - "roomList": true, - "roomAdmin": true, - "roomRecord": false, - "ingressAdmin": false, + "room": room, + "roomAdmin": true, }, } header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) diff --git a/internal/voice/token_test.go b/internal/voice/token_test.go index 4f77ef3..8681e8d 100644 --- a/internal/voice/token_test.go +++ b/internal/voice/token_test.go @@ -61,6 +61,43 @@ func TestIssueTokenHasLiveKitClaims(t *testing.T) { } } +func TestIssueAdminTokenIsRoomScoped(t *testing.T) { + issuer := voice.NewIssuer("key123", "secret456", time.Hour) + token, err := issuer.IssueAdmin("guild_1_channel_2") + if err != nil { + t.Fatalf("IssueAdmin: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var claims struct { + Issuer string `json:"iss"` + Video struct { + Room string `json:"room"` + RoomAdmin bool `json:"roomAdmin"` + } `json:"video"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatalf("decode claims: %v", err) + } + // LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room` + // RoomService отвечает 401 `permissions denied` (проверено на 1.9.7). + if claims.Issuer != "key123" || !claims.Video.RoomAdmin { + t.Fatalf("административные права = %+v", claims) + } + if claims.Video.Room != "guild_1_channel_2" { + t.Fatalf("комната в грахте = %q", claims.Video.Room) + } + if _, err := issuer.IssueAdmin(" "); err == nil { + t.Fatal("пустая комната должна отклоняться") + } +} + func TestIssuerDisabledWithoutKeys(t *testing.T) { issuer := voice.NewIssuer("", "", time.Hour) if issuer.Enabled() { diff --git a/web/e2e/voice.spec.ts b/web/e2e/voice.spec.ts index 2ca0a27..3adaffc 100644 --- a/web/e2e/voice.spec.ts +++ b/web/e2e/voice.spec.ts @@ -99,29 +99,44 @@ async function finishOnboarding(api: APIRequestContext): Promise { expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy(); } -/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */ -async function prepare(api: APIRequestContext): Promise { - 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 { + 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 { 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 { }); 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 { 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 { + 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> | null = null; let guest: Awaited> | 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(); } }); });