diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 320f668..8c77899 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -1864,6 +1864,13 @@ func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64 if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin { return humaErrorStatus(http.StatusForbidden, "guild.invite_required", "this guild requires an invite") } + // Бан сервера закрывает вход и в публичный сервер: иначе достаточно было бы + // нажать «Войти» вместо приглашения (AGENT.md 7.17, 7.20). + if banned, err := s.store.IsGuildBanned(ctx, guildID, user.ID); err != nil { + return humaError(err) + } else if banned { + return humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild") + } settings, err := s.store.InstanceSettings(ctx) if err != nil { return humaError(err) diff --git a/internal/server/moderation_test.go b/internal/server/moderation_test.go index 8a04379..a58779b 100644 --- a/internal/server/moderation_test.go +++ b/internal/server/moderation_test.go @@ -101,6 +101,16 @@ func TestBanMemberFlow(t *testing.T) { t.Fatalf("код ошибки = %q", code) } + // Открытый сервер (главный открыт всегда) тоже закрыт для забаненного: + // иначе бан обходится нажатием «Войти» вместо приглашения (AGENT.md 7.20). + byJoin := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/join", "", member) + if byJoin.Code != http.StatusForbidden { + t.Fatalf("вход забаненного в публичный сервер = %d, ожидался 403", byJoin.Code) + } + if code := errorCodeOf(t, byJoin); code != "guild.banned" { + t.Fatalf("код ошибки публичного входа = %q", code) + } + // Снятие бана возвращает возможность войти. unban := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/bans/"+memberID, "", owner) if unban.Code != http.StatusOK { diff --git a/web/.prettierignore b/web/.prettierignore index b2b15f2..f0550c3 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,5 +2,5 @@ dist node_modules coverage *.min.js -test-results +test-results* playwright-report diff --git a/web/e2e/voice-ten.spec.ts b/web/e2e/voice-ten.spec.ts index 66ee069..2c0ee16 100644 --- a/web/e2e/voice-ten.spec.ts +++ b/web/e2e/voice-ten.spec.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { expect, test, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; /** * Голосовая нагрузка (AGENT.md 9.1, 11.4): десять участников в одной комнате @@ -17,8 +17,6 @@ const CREDS_PATH = process.env.GLCHAT_LOADTEST_CREDS ?? '../../build/loadtest-us const FIXTURE_PATH = process.env.GLCHAT_LOADTEST_FIXTURE ?? '../../build/loadtest-fixture.json'; const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10'); const EXPECT_SCREENSHARE = process.env.GLCHAT_EXPECT_SCREENSHARE === '1'; -// Идентификатор внешнего издателя: реальный участник сервера (см. build/loadtest-voice.sh). -const SCREENSHARE_IDENTITY = process.env.GLCHAT_SCREENSHARE_IDENTITY ?? 'screenshare-1080p60'; interface Account { id: string; @@ -40,41 +38,6 @@ function readFixture(): Fixture { return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Fixture; } -/** signInWithToken открывает контекст с готовой сессией: форма входа не нужна. */ -async function signInWithToken( - browser: Browser, - account: Account, - baseURL: string, -): Promise<{ context: BrowserContext; page: Page }> { - const context = await browser.newContext({ - permissions: ['microphone', 'camera'], - baseURL, - }); - await context.addCookies([ - // Cookie сессии — __Host-session (internal/gateway/session.go). - { name: '__Host-session', value: account.token, url: baseURL, httpOnly: true, secure: true }, - ]); - // Аккаунты создаёт команда обслуживания, онбординг у них не пройден: без - // этого AuthGuard уводит браузер на /onboarding и комнаты не видно. - const headers = { Authorization: `Bearer ${account.token}` }; - const me = await context.request.get('/api/v1/users/@me', { headers }); - if (me.ok()) { - const payload = (await me.json()) as { - user: { onboarding_completed: boolean; display_name?: string | null; username: string }; - }; - if (!payload.user.onboarding_completed) { - await context.request.post('/api/v1/users/@me/onboarding/complete', { - headers, - data: { display_name: payload.user.display_name ?? payload.user.username }, - }); - } - } - const page = await context.newPage(); - await page.goto('/app'); - await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); - return { context, page }; -} - /** joinVoice нажимает «Войти» в голосовой комнате и ждёт подключения. */ async function joinVoice(page: Page): Promise { const button = page.getByTestId('voice-join'); @@ -101,11 +64,17 @@ async function inboundAudioBytes(page: Page): Promise { }); } -/** inboundVideoStats отдаёт лучший входящий видеопоток: размер и кадры в секунду. */ -async function inboundVideoStats( - page: Page, -): Promise<{ width: number; height: number; fps: number }> { - return page.evaluate(async () => { +interface VideoStats { + width: number; + height: number; + fps: number; + decoded: number; + bytes: number; +} + +/** inboundVideoStats отдаёт лучший входящий видеопоток: размер, кадры и байты. */ +async function inboundVideoStats(page: Page): Promise { + return page.evaluate(async () => { const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] }; let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 }; for (const pc of globalWindow.__pcs ?? []) { @@ -209,15 +178,15 @@ test.describe('голосовая нагрузка: десять участни // декодирование 60 кадров в секунду меряет независимый подписчик — // build/verify-1080p60-browser.mjs (adaptive stream выключен). await expect - .poll(async () => inboundVideoStats(sessions[0]!.page), { + .poll(async () => inboundVideoStats(sessions[0].page), { timeout: 90_000, intervals: [2000], }) .toMatchObject({ width: 1920, height: 1080 }); - const stats = await inboundVideoStats(sessions[0]!.page); + const stats = await inboundVideoStats(sessions[0].page); console.log('входящее видео:', JSON.stringify(stats)); await expect - .poll(async () => (await inboundVideoStats(sessions[0]!.page)).bytes, { + .poll(async () => (await inboundVideoStats(sessions[0].page)).bytes, { timeout: 60_000, intervals: [2000], }) diff --git a/web/eslint.config.js b/web/eslint.config.js index 20038f5..53c08b1 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -42,4 +42,10 @@ export default tseslint.config( files: ['eslint.config.js'], extends: [tseslint.configs.disableTypeChecked], }, + // e2e-тесты печатают диагностику прогона (шаги, размеры потоков, fps), + // поэтому console.log в них разрешён: без него живой тест не отладить. + { + files: ['e2e/**/*.ts'], + rules: { 'no-console': 'off' }, + }, ); diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index f53ef3c..f87c488 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -189,6 +189,9 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { // removeGuild сам выбирает первый оставшийся сервер (или пустой список). session.removeGuild(guildId); void invalidateGuildQueries(guildId); + // Снапшот и REST-кэш должны совпадать: иначе пустой стор подставит + // устаревший myGuilds и сервер вернётся в рейку. + void invalidateMyGuilds(); syncUrlWithSelection(); return; } @@ -354,6 +357,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { session.reset(); messages.reset(); useVoiceConnectionStore.getState().reset(); + void invalidateMyGuilds(); void invalidateCurrentUser(); return; } @@ -490,6 +494,15 @@ async function invalidateMembershipQueries(guildId: string): Promise { ]); } +/** Инвалидация списка серверов: держит REST-кэш в согласии со снапшотом. */ +async function invalidateMyGuilds(): Promise { + const [{ getQueryClient }, users] = await Promise.all([ + import('@/lib/queryClient'), + import('@/api/users'), + ]); + await getQueryClient().invalidateQueries({ queryKey: users.myGuildsQueryKey }); +} + /** Инвалидация данных удалённого сервера (карточка, участники, каналы). */ async function invalidateGuildQueries(guildId: string): Promise { const [{ getQueryClient }, guilds] = await Promise.all([ @@ -555,8 +568,10 @@ async function invalidateCurrentUser(): Promise { import('@/lib/queryClient'), import('@/api/users'), ]); - // removeQueries вместо invalidate: сессия мертва, повторный запрос не нужен. + // Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по + // 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6). getQueryClient().removeQueries({ queryKey: currentUserQueryKey }); + await getQueryClient().refetchQueries({ queryKey: currentUserQueryKey }); } async function refetchCurrentUser(): Promise {