Files
glchat/web/tests/voice.test.tsx
T
grendervill a48b865bc9 refactor: убрать вход через внешние провайдеры (OAuth) полностью
Решение владельца 27.09.2026 (D-084): сервис ставится на сервер конкретного
человека, и ему всё равно нужен свой OAuth у провайдера — поддержка общего
входа только создаёт трение (регистрация приложений, redirect URI, модерация,
чужие ключи в конфиге). Способы входа остаются: пароль + 2FA и ключи доступа
(WebAuthn).

Удалено:
- сервер: internal/auth/oauth.go, internal/server/oauth.go, internal/store/oauth.go
  и их тесты; поля и методы конфига OAuth*; oauthLimiter и регистрация ручек;
  отображение ошибок oauth.*; features.oauth_enabled/oauth_providers в /meta;
- клиент: web/src/api/oauth.ts, раздел «Вход через внешние сервисы», кнопки
  провайдеров на странице входа, ключи i18n (ru/en), тесты и фикстуры;
- установщик: переменные OAUTH_* из .env, .env.example и шаблона (хелпер
  чтения существующих значений переименован в existing_value — он остался нужен
  для VAPID_SUBJECT);
- зависимость golang.org/x/oauth2 (go mod tidy).

Схема: миграция 00027 удаляет таблицу oauth_accounts (00019 не переписываем —
она применена на стендах). Откат миграции возвращает структуру; тест
TestOAuthRemovalMigration проверяет накат, откат и повторный накат.

AGENT.md (локальный) помечает пункты про OAuth как отменённые.
2026-09-27 18:27:33 +03:00

569 lines
21 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('livekit-client', async () => {
const fake = await import('./fakeLiveKit');
return {
Room: fake.FakeRoom,
RoomEvent: fake.RoomEvent,
ConnectionQuality: fake.ConnectionQuality,
Track: fake.Track,
VideoQuality: fake.VideoQuality,
};
});
import { FakeRoom } from './fakeLiveKit';
import {
installFetch,
installGatewaySocket,
json,
makeReadySnapshot,
makeUser,
recordedRequests,
renderApp,
type FetchRoute,
type RecordedRequest,
} from './helpers';
import { makeVoiceState, voiceRoutes, type VoiceRoutesOptions } from './voiceRoutes';
import type { InstanceMeta } from '@/api/meta';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { useVoiceStore } from '@/stores/voice';
const ALL_PERMISSIONS = [
'VIEW_GUILD',
'SEND_MESSAGES',
'CONNECT_VOICE',
'MUTE_MEMBERS',
'DEAFEN_MEMBERS',
'MOVE_MEMBERS',
];
const members = [
{
user_id: 'user-1',
username: 'alice',
display_name: 'Alice',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-19T09:00:00Z',
role_ids: [],
},
{
user_id: 'user-2',
username: 'bob',
display_name: 'Bob',
status: 'online',
is_instance_admin: false,
joined_at: '2026-09-19T09:00:00Z',
role_ids: [],
},
];
const enabledMeta: InstanceMeta = {
name: 'glchat',
version: 'v0.1.0-test',
commit: 'deadbee',
build_date: '2026-09-19T00:00:00Z',
api_version: 'v1',
base_url: 'https://gl.localhost',
files_url: 'https://files.gl.localhost',
gateway_url: 'wss://gl.localhost/gateway',
rtc_path: '/rtc',
max_upload_size: 26_214_400,
features: {
registration_enabled: true,
anti_bot_enabled: false,
voice_enabled: true,
web_push_enabled: false,
unfurl_enabled: false,
passkeys_enabled: false,
},
};
interface BootOptions extends VoiceRoutesOptions {
path?: string;
permissions?: string[];
/** Участники, которые уже сидят в комнатах (снапшот READY). */
seated?: Parameters<typeof makeVoiceState>[0][];
meta?: InstanceMeta;
}
/** Поднимает приложение, играет рукопожатие шлюза и мокает голосовые ручки. */
async function bootVoice(options: BootOptions = {}) {
const snapshot = makeReadySnapshot([
{
id: 'g-1',
name: 'Сервер',
is_main: true,
my_permissions: options.permissions ?? ALL_PERMISSIONS,
channels: [
{ id: 'c-general', name: 'общий', position: 0 },
{ id: 'c-voice', name: 'Голосовая', type: 'voice', position: 1 },
{ id: 'c-other', name: 'Вторая', type: 'voice', position: 2 },
],
voice_states: options.seated ?? [],
},
]);
const voice = voiceRoutes({
channelIds: ['c-voice', 'c-other'],
...options,
// READY и REST должны рассказывать про один и тот же состав комнат.
states: (options.states ?? options.seated ?? []).map((state) => makeVoiceState(state)),
});
const fetchMock = installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user: makeUser() }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/meta', response: () => json(options.meta ?? enabledMeta) },
...voice.routes,
] as FetchRoute[]);
const gateway = installGatewaySocket(snapshot);
renderApp(options.path ?? '/app/g-1/c-general');
await screen.findByTestId('guild-rail');
await gateway.greet();
return { ...voice, fetchMock, gateway };
}
/** Последний запрос по фильтру: так проверяются PATCH и leave. */
function lastRequest(
fetchMock: ReturnType<typeof installFetch>,
predicate: { url: string; method?: string },
): RecordedRequest | undefined {
return recordedRequests(fetchMock)
.filter(
(request) =>
request.url.includes(predicate.url) &&
(predicate.method === undefined || request.method === predicate.method.toUpperCase()),
)
.at(-1);
}
/** Клик по голосовой комнате в сайдбаре: вход и переход на её страницу. */
async function clickChannel(name: string): Promise<void> {
await userEvent.click(await screen.findByLabelText(`Открыть комнату ${name}`));
}
async function waitConnected(): Promise<void> {
await waitFor(() => {
expect(useVoiceConnectionStore.getState().status).toBe('connected');
});
}
beforeEach(() => {
FakeRoom.reset();
});
describe('голосовая комната: вход', () => {
it('входит по клику на канал и рисует участников', async () => {
const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
// Сервер записал состояние, комната SDK создана.
expect(voice.joinCount()).toBe(1);
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
expect(await screen.findByTestId('voice-tile-user-2')).toBeVisible();
expect(screen.getByTestId('voice-tile-user-1')).toBeVisible();
// Список участников под голосовой комнатой в сайдбаре.
const list = screen.getByLabelText('Участники комнаты «Голосовая»');
expect(within(list).getByLabelText('Участник Bob')).toBeVisible();
});
it('подключается кнопкой в панели комнаты', async () => {
await bootVoice({ path: '/app/g-1/c-voice' });
await userEvent.click(await screen.findByTestId('voice-join'));
await waitConnected();
expect(FakeRoom.latest().connectCalls).toHaveLength(1);
});
it('при выключенном голосе показывает подсказку вместо подключения', async () => {
const voice = await bootVoice({
meta: { ...enabledMeta, features: { ...enabledMeta.features, voice_enabled: false } },
});
await clickChannel('Голосовая');
expect(await screen.findByTestId('voice-disabled')).toHaveTextContent(
'Голос не настроен на инстансе',
);
expect(screen.queryByTestId('voice-join')).toBeNull();
expect(voice.joinCount()).toBe(0);
});
it('показывает пинг участника в подсказке индикатора связи', { timeout: 20_000 }, async () => {
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
// Свой пинг приходит из отчёта WebRTC (candidate-pair) и публикуется
// в комнату; у чужого участника он приходит по data-каналу.
const room = FakeRoom.latest();
room.setStats([
['pair', { type: 'candidate-pair', state: 'succeeded', currentRoundTripTime: 0.042 }],
]);
// Первый такт может прийтись на момент, когда микрофон ещё не опубликован,
// поэтому ждём следующего (интервал публикации пинга — секунда). Пинг виден
// только при наведении: он попадает в подсказку индикатора связи.
const ownQuality = () =>
within(screen.getByTestId('voice-tile-user-1')).getByTestId('voice-quality');
await waitFor(
() => {
expect(ownQuality()).toHaveAttribute(
'title',
expect.stringContaining('Отличное соединение, 42 мс'),
);
},
{ timeout: 5_000 },
);
expect(ownQuality()).toHaveAttribute('data-latency', '42');
expect(ownQuality()).toHaveAttribute(
'aria-label',
expect.stringContaining('задержка до голосового сервера'),
);
// Второй участник подключается к комнате и присылает свой пинг.
const remote = room.addRemote('user-2', 'Bob');
act(() => {
room.emitData(
new TextEncoder().encode(JSON.stringify({ ms: 128 })),
remote,
'glchat.latency',
);
});
const remoteTile = await screen.findByTestId('voice-tile-user-2');
await waitFor(
() => {
expect(within(remoteTile).getByTestId('voice-quality')).toHaveAttribute(
'title',
expect.stringContaining('Отличное соединение, 128 мс'),
);
},
{ timeout: 5_000 },
);
// Свой пинг уходит в комнату по служебной теме и без гарантии доставки.
await waitFor(
() => {
expect(room.localParticipant.data.length).toBeGreaterThan(0);
},
{ timeout: 5_000 },
);
const sent = room.localParticipant.data.at(-1);
expect(sent?.options).toMatchObject({ topic: 'glchat.latency', reliable: false });
expect(new TextDecoder().decode(sent?.payload ?? new Uint8Array())).toContain('"ms"');
});
it('выключенный пинг участников не опрашивает SDK', async () => {
useVoiceStore.setState({ latencySharingEnabled: false });
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
const track = FakeRoom.latest().localParticipant.getTrackPublication('microphone')?.track;
const before = track?.statsRequests ?? 0;
await new Promise((resolve) => setTimeout(resolve, 1_500));
expect(track?.statsRequests).toBe(before);
expect(useVoiceConnectionStore.getState().latencies).toEqual({});
});
it('переходит в другую комнату через выход из старой', async () => {
const voice = await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
await clickChannel('Вторая');
await waitFor(() => {
expect(useVoiceConnectionStore.getState().channelId).toBe('c-other');
});
const requests = recordedRequests(voice.fetchMock)
.filter((request) => request.url.includes('/voice/'))
.map((request) => `${request.method} ${request.url.split('/api/v1')[1] ?? ''}`);
expect(requests).toEqual([
'POST /channels/c-voice/voice/join',
'POST /channels/c-voice/voice/leave',
'POST /channels/c-other/voice/join',
]);
});
it('показывает ошибку «в комнате нет свободных мест»', async () => {
await bootVoice({ joinError: { code: 'voice.channel_full', status: 403 } });
await clickChannel('Голосовая');
// Ошибку показывают и сайдбар под комнатой, и панель комнаты.
expect((await screen.findAllByText('В комнате нет свободных мест.')).length).toBeGreaterThan(0);
expect(useVoiceConnectionStore.getState().status).toBe('error');
});
it('показывает ошибку нехватки прав', async () => {
await bootVoice({ joinError: { code: 'perm.denied', status: 403 } });
await clickChannel('Голосовая');
expect(
(await screen.findAllByText('Недостаточно прав для этого действия.')).length,
).toBeGreaterThan(0);
});
});
describe('голосовая комната: панель пользователя', () => {
it('кнопки микрофона, камеры и наушников управляют SDK и уходят на сервер', async () => {
const { fetchMock } = await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
const panel = screen.getByLabelText('Панель пользователя');
await userEvent.click(within(panel).getByLabelText('Выключить микрофон'));
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
method: 'setMicrophoneEnabled',
value: false,
});
await waitFor(() => {
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
self_mute: true,
});
});
expect(within(panel).getByLabelText('Включить микрофон')).toHaveAttribute(
'aria-pressed',
'false',
);
await userEvent.click(within(panel).getByLabelText('Включить камеру'));
expect(FakeRoom.latest().localParticipant.calls).toContainEqual({
method: 'setCameraEnabled',
value: true,
});
await waitFor(() => {
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
camera: true,
});
});
await userEvent.click(within(panel).getByLabelText('Выключить звук'));
await waitFor(() => {
expect(lastRequest(fetchMock, { url: '/voice-states/@me', method: 'PATCH' })?.body).toEqual({
self_deaf: true,
});
});
});
it('серверный мьют блокирует кнопку микрофона с подсказкой', async () => {
const { gateway } = await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
act(() => {
gateway.dispatch(
'VOICE_STATE_UPDATE',
makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_mute: true }),
);
});
const panel = screen.getByLabelText('Панель пользователя');
const mic = await within(panel).findByLabelText('Микрофон выключен модератором');
expect(mic).toBeDisabled();
expect(mic).toHaveAttribute('title', 'Микрофон выключен модератором');
expect(useVoiceConnectionStore.getState().micEnabled).toBe(false);
});
it('серверное глушение блокирует кнопку наушников с подсказкой', async () => {
const { gateway } = await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
act(() => {
gateway.dispatch(
'VOICE_STATE_UPDATE',
makeVoiceState({ user_id: 'user-1', channel_id: 'c-voice', server_deaf: true }),
);
});
const panel = screen.getByLabelText('Панель пользователя');
const sound = await within(panel).findByLabelText('Звук выключен модератором');
expect(sound).toBeDisabled();
});
it('смена устройства применяется к сессии', async () => {
await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
await act(async () => {
await useVoiceConnectionStore.getState().switchActiveDevice('audioinput', 'mic-2');
});
expect(FakeRoom.latest().deviceSwitches).toEqual([{ kind: 'audioinput', deviceId: 'mic-2' }]);
});
});
describe('голосовая комната: события шлюза', () => {
it('VOICE_STATE_UPDATE добавляет и убирает участника без перезагрузки', async () => {
const { gateway } = await bootVoice();
expect(screen.queryByLabelText('Участник Bob')).toBeNull();
act(() => {
gateway.dispatch(
'VOICE_STATE_UPDATE',
makeVoiceState({ user_id: 'user-2', channel_id: 'c-voice' }),
);
});
expect(await screen.findByLabelText('Участник Bob')).toBeVisible();
act(() => {
gateway.dispatch('VOICE_STATE_UPDATE', {
guild_id: 'g-1',
channel_id: 'c-voice',
user_id: 'user-2',
left: true,
});
});
await waitFor(() => {
expect(screen.queryByLabelText('Участник Bob')).toBeNull();
});
});
it('показывает индикацию речи участника', async () => {
await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
act(() => {
FakeRoom.latest().addRemote('user-2', 'Bob');
FakeRoom.latest().activeSpeakers(['user-2']);
});
await waitFor(() => {
expect(screen.getByTestId('voice-tile-user-2')).toHaveAttribute('data-speaking', 'true');
});
});
it('показывает и скрывает подсказку о речи при выключенном микрофоне', async () => {
await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
await act(async () => {
await useVoiceConnectionStore.getState().setMicEnabled(false);
});
act(() => {
FakeRoom.latest().activeSpeakers(['user-1']);
});
const notice = await screen.findByTestId('voice-speaking-notice');
expect(notice).toHaveTextContent('Вы говорите с выключенным микрофоном');
await userEvent.click(within(notice).getByLabelText('Скрыть'));
await waitFor(() => {
expect(screen.queryByTestId('voice-speaking-notice')).toBeNull();
});
});
});
describe('голосовая комната: выход', () => {
it('отключается по кнопке и очищает состояние', async () => {
const { fetchMock } = await bootVoice({
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
});
await clickChannel('Голосовая');
await waitConnected();
const tile = screen.getByTestId('voice-tile-user-1');
await userEvent.click(within(tile).getByLabelText('Отключиться'));
await waitFor(() => {
expect(useVoiceConnectionStore.getState().status).toBe('idle');
});
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
expect(screen.queryByTestId('voice-tile-user-1')).toBeNull();
// Своя строка исчезла из сайдбара, а оставшийся участник — на месте.
expect(screen.queryByLabelText('Участник Alice')).toBeNull();
expect(screen.getAllByLabelText('Участник Bob').length).toBeGreaterThan(0);
});
it('отключается по двойному клику на комнату в сайдбаре', async () => {
const { fetchMock } = await bootVoice();
await clickChannel('Голосовая');
await waitConnected();
await userEvent.dblClick(await screen.findByLabelText('Открыть комнату Голосовая'));
await waitFor(() => {
expect(useVoiceConnectionStore.getState().status).toBe('idle');
});
expect(lastRequest(fetchMock, { url: '/voice/leave', method: 'POST' })).toBeDefined();
});
});
describe('голосовая комната: модерация', () => {
it('серверный мьют участника из меню', async () => {
const voice = await bootVoice({ seated: [{ user_id: 'user-2', channel_id: 'c-voice' }] });
await clickChannel('Голосовая');
await waitConnected();
await userEvent.click(
within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
);
await userEvent.click(await screen.findByRole('menuitem', { name: 'Выключить микрофон' }));
await waitFor(() => {
expect(voice.states.find((state) => state.user_id === 'user-2')?.server_mute).toBe(true);
});
expect(
lastRequest(voice.fetchMock, { url: '/voice-states/user-2', method: 'PATCH' })?.body,
).toEqual({ server_mute: true });
});
it('перемещение участника в другую комнату из меню', async () => {
const voice = await bootVoice({
channelIds: ['c-voice', 'c-other'],
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
});
await clickChannel('Голосовая');
await waitConnected();
await userEvent.click(
within(await screen.findByTestId('voice-tile-user-2')).getByLabelText('Участник Bob'),
);
await userEvent.click(await screen.findByRole('menuitem', { name: 'Переместить в «Вторая»' }));
await waitFor(() => {
expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
});
});
it('перемещает участника перетаскиванием на голосовую комнату', async () => {
const voice = await bootVoice({
channelIds: ['c-voice', 'c-other'],
seated: [{ user_id: 'user-2', channel_id: 'c-voice' }],
});
const target = await screen.findByLabelText('Открыть комнату Вторая');
const dataTransfer = {
types: ['application/x-glchat-voice-user'],
getData: () => 'user-2',
setData: vi.fn(),
dropEffect: 'none',
};
fireEvent.dragOver(target, { dataTransfer });
fireEvent.drop(target, { dataTransfer });
await waitFor(() => {
expect(voice.states.find((state) => state.user_id === 'user-2')?.channel_id).toBe('c-other');
});
});
});