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:
+8
-12
@@ -53,7 +53,7 @@ func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string
|
|||||||
var response struct {
|
var response struct {
|
||||||
Participant Participant `json:"participant"`
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,14 +78,14 @@ func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity str
|
|||||||
// Участник без микрофона: мьютить нечего, это не ошибка.
|
// Участник без микрофона: мьютить нечего, это не ошибка.
|
||||||
return nil
|
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,
|
"room": room, "identity": identity, "track_sid": trackSID, "muted": muted,
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
|
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
|
||||||
func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error {
|
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 перечисляет участников комнаты: используется для проверки
|
// ListParticipants перечисляет участников комнаты: используется для проверки
|
||||||
@@ -94,14 +94,15 @@ func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Part
|
|||||||
var response struct {
|
var response struct {
|
||||||
Participants []Participant `json:"participants"`
|
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 nil, err
|
||||||
}
|
}
|
||||||
return response.Participants, nil
|
return response.Participants, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// call выполняет запрос к Twirp-методу RoomService с административным токеном.
|
// call выполняет запрос к Twirp-методу RoomService с административным токеном
|
||||||
func (c *AdminClient) call(ctx context.Context, method string, body any, out any) error {
|
// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты.
|
||||||
|
func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error {
|
||||||
if !c.Enabled() {
|
if !c.Enabled() {
|
||||||
return ErrDisabled
|
return ErrDisabled
|
||||||
}
|
}
|
||||||
@@ -109,7 +110,7 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
token, err := c.adminToken()
|
token, err := c.issuer.IssueAdmin(room)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -141,8 +142,3 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminToken подписывает административный токен RoomService.
|
|
||||||
func (c *AdminClient) adminToken() (string, error) {
|
|
||||||
return c.issuer.IssueAdmin()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -120,12 +120,17 @@ func encodeSegment(payload []byte) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(payload)
|
return base64.RawURLEncoding.EncodeToString(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueAdmin подписывает административный токен RoomService: полные права на
|
// IssueAdmin подписывает административный токен RoomService: права на
|
||||||
// комнаты (модерация, удаление участников), но не на запись медиа.
|
// модерацию одной комнаты (мьют дорожки, удаление участника), но не на запись
|
||||||
func (i *TokenIssuer) IssueAdmin() (string, error) {
|
// медиа. LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room` в
|
||||||
|
// грахте RoomService отвечает 401 `permissions denied`.
|
||||||
|
func (i *TokenIssuer) IssueAdmin(room string) (string, error) {
|
||||||
if !i.Enabled() {
|
if !i.Enabled() {
|
||||||
return "", ErrDisabled
|
return "", ErrDisabled
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(room) == "" {
|
||||||
|
return "", fmt.Errorf("voice.invalid_room: пустое имя комнаты")
|
||||||
|
}
|
||||||
now := i.now().UTC()
|
now := i.now().UTC()
|
||||||
claims := map[string]any{
|
claims := map[string]any{
|
||||||
"iss": i.apiKey,
|
"iss": i.apiKey,
|
||||||
@@ -133,11 +138,8 @@ func (i *TokenIssuer) IssueAdmin() (string, error) {
|
|||||||
"nbf": now.Add(-10 * time.Second).Unix(),
|
"nbf": now.Add(-10 * time.Second).Unix(),
|
||||||
"exp": now.Add(10 * time.Minute).Unix(),
|
"exp": now.Add(10 * time.Minute).Unix(),
|
||||||
"video": map[string]any{
|
"video": map[string]any{
|
||||||
"roomCreate": true,
|
"room": room,
|
||||||
"roomList": true,
|
|
||||||
"roomAdmin": true,
|
"roomAdmin": true,
|
||||||
"roomRecord": false,
|
|
||||||
"ingressAdmin": false,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||||
|
|||||||
@@ -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) {
|
func TestIssuerDisabledWithoutKeys(t *testing.T) {
|
||||||
issuer := voice.NewIssuer("", "", time.Hour)
|
issuer := voice.NewIssuer("", "", time.Hour)
|
||||||
if issuer.Enabled() {
|
if issuer.Enabled() {
|
||||||
|
|||||||
+53
-25
@@ -99,29 +99,44 @@ async function finishOnboarding(api: APIRequestContext): Promise<void> {
|
|||||||
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
|
expect(done.ok(), `завершение онбординга: ${await done.text()}`).toBeTruthy();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** prepare создаёт сервер, голосовую комнату и второго пользователя по приглашению. */
|
/**
|
||||||
async function prepare(api: APIRequestContext): Promise<Fixture> {
|
* loginAsAdmin логинит api-контекст под инстанс-администратором и возвращает
|
||||||
const adminLogin = await api.post('/api/v1/auth/login', {
|
* его профиль. Код 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) },
|
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD, totp_code: totp(ADMIN_TOTP) },
|
||||||
});
|
});
|
||||||
expect(adminLogin.ok(), `вход администратора: ${await adminLogin.text()}`).toBeTruthy();
|
expect(response.ok(), `вход администратора: ${await response.text()}`).toBeTruthy();
|
||||||
const admin = (await adminLogin.json()) as { user: { id: string } };
|
const payload = (await response.json()) as { user: { id: string } };
|
||||||
await finishOnboarding(api);
|
return payload.user;
|
||||||
await dropStaleGuilds(api);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 stamp = Date.now();
|
||||||
const guildName = `Voice E2E ${stamp}`;
|
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();
|
expect(guild.ok(), `создание сервера: ${await guild.text()}`).toBeTruthy();
|
||||||
const guildId = ((await guild.json()) as { guild: { id: string } }).guild.id;
|
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' },
|
data: { name: 'Голос', type: 'voice' },
|
||||||
});
|
});
|
||||||
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
|
expect(channel.ok(), `создание комнаты: ${await channel.text()}`).toBeTruthy();
|
||||||
const channelId = ((await channel.json()) as { channel: { id: string } }).channel.id;
|
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 },
|
data: { max_uses: 1, max_age_seconds: 600 },
|
||||||
});
|
});
|
||||||
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
expect(invite.ok(), `создание приглашения: ${await invite.text()}`).toBeTruthy();
|
||||||
@@ -130,7 +145,7 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
|
|||||||
const guestLogin = `voice${stamp}`;
|
const guestLogin = `voice${stamp}`;
|
||||||
const guestEmail = `${guestLogin}@gl.mhspx.su`;
|
const guestEmail = `${guestLogin}@gl.mhspx.su`;
|
||||||
const guestPassword = 'Voice-Probe-Password-9x';
|
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: {
|
data: {
|
||||||
username: guestLogin,
|
username: guestLogin,
|
||||||
email: guestEmail,
|
email: guestEmail,
|
||||||
@@ -139,10 +154,9 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
|
|||||||
});
|
});
|
||||||
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
|
expect(guestContext.ok(), `регистрация гостя: ${await guestContext.text()}`).toBeTruthy();
|
||||||
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
|
const guestId = ((await guestContext.json()) as { user: { id: string } }).user.id;
|
||||||
// Регистрация переключила сессию api-контекста на гостя — доводим его до /app.
|
await finishOnboarding(guest);
|
||||||
await finishOnboarding(api);
|
|
||||||
|
|
||||||
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();
|
expect(accept.ok(), `принятие приглашения: ${await accept.text()}`).toBeTruthy();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -151,7 +165,7 @@ async function prepare(api: APIRequestContext): Promise<Fixture> {
|
|||||||
channelId,
|
channelId,
|
||||||
guestEmail,
|
guestEmail,
|
||||||
guestPassword,
|
guestPassword,
|
||||||
adminId: admin.user.id,
|
adminId: adminUser.id,
|
||||||
guestId,
|
guestId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -211,15 +225,28 @@ async function signIn(
|
|||||||
return { context, page };
|
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.describe('голос: два клиента в одной комнате', () => {
|
||||||
test.skip(!enabled, 'нужен развёрнутый инстанс: GLCHAT_E2E_VOICE=1');
|
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 }) => {
|
test('аудио доходит через LiveKit, модерация применяется', async ({ browser, playwright }) => {
|
||||||
const api = await playwright.request.newContext({
|
const baseURL = process.env.GLCHAT_URL ?? 'https://gl.mhspx.su';
|
||||||
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(api);
|
const fixture = await prepare(adminApi, guestApi);
|
||||||
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
|
let admin: Awaited<ReturnType<typeof signIn>> | null = null;
|
||||||
let guest: 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 })
|
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
||||||
.click();
|
.click();
|
||||||
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).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.adminId}`)).toBeVisible();
|
||||||
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
await expect(session.page.getByTestId(`voice-tile-${fixture.guestId}`)).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -259,7 +286,7 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Серверный мьют гостя применяется на стороне SFU.
|
// Серверный мьют гостя применяется на стороне SFU.
|
||||||
const mute = await api.patch(
|
const mute = await adminApi.patch(
|
||||||
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
||||||
{
|
{
|
||||||
data: { server_mute: true },
|
data: { server_mute: true },
|
||||||
@@ -274,7 +301,7 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
await expect(guestMic).toBeDisabled({ timeout: 30_000 });
|
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}`,
|
`/api/v1/guilds/${fixture.guildId}/voice-states/${fixture.guestId}`,
|
||||||
);
|
);
|
||||||
expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy();
|
expect(drop.ok(), `отключение от голосовой: ${await drop.text()}`).toBeTruthy();
|
||||||
@@ -286,10 +313,11 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
|
// Уборка: тестовый сервер удаляется вместе с комнатами и состояниями.
|
||||||
await api.delete(`/api/v1/guilds/${fixture.guildId}`);
|
await adminApi.delete(`/api/v1/guilds/${fixture.guildId}`);
|
||||||
await admin?.context.close();
|
await admin?.context.close();
|
||||||
await guest?.context.close();
|
await guest?.context.close();
|
||||||
await api.dispose();
|
await adminApi.dispose();
|
||||||
|
await guestApi.dispose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user