fix(realtime): уход на вход после отзыва сессии и бан в открытом сервере

- invalidateCurrentUser перечитывает профиль, а не только удаляет кэш: без
  запроса AuthGuard не видит 401 и второе устройство остаётся в приложении
  после logout-all или смены пароля (AGENT.md 11.6, пункт 12);
- сброс сессии и уход из последнего сервера инвалидируют список серверов:
  пустой снапшот подставлял устаревший REST-ответ, и сервер возвращался в
  рейку (видно и после кика);
- joinGuild проверяет бан сервера: публичный сервер больше не обходится
  нажатием «Войти» вместо приглашения (AGENT.md 7.17, 7.20), тест расширен;
- e2e/voice-ten приведён к линту, console.log разрешён в e2e (диагностика
  прогона), артефакты Playwright исключены из prettier.
This commit is contained in:
2026-09-22 20:35:13 +03:00
parent b2a48d8008
commit 7ec9dbfdee
6 changed files with 55 additions and 48 deletions
+7
View File
@@ -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)
+10
View File
@@ -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 {
+1 -1
View File
@@ -2,5 +2,5 @@ dist
node_modules
coverage
*.min.js
test-results
test-results*
playwright-report
+15 -46
View File
@@ -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<void> {
const button = page.getByTestId('voice-join');
@@ -101,11 +64,17 @@ async function inboundAudioBytes(page: Page): Promise<number> {
});
}
/** 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<VideoStats> {
return page.evaluate<VideoStats>(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],
})
+6
View File
@@ -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' },
},
);
+16 -1
View File
@@ -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<void> {
]);
}
/** Инвалидация списка серверов: держит REST-кэш в согласии со снапшотом. */
async function invalidateMyGuilds(): Promise<void> {
const [{ getQueryClient }, users] = await Promise.all([
import('@/lib/queryClient'),
import('@/api/users'),
]);
await getQueryClient().invalidateQueries({ queryKey: users.myGuildsQueryKey });
}
/** Инвалидация данных удалённого сервера (карточка, участники, каналы). */
async function invalidateGuildQueries(guildId: string): Promise<void> {
const [{ getQueryClient }, guilds] = await Promise.all([
@@ -555,8 +568,10 @@ async function invalidateCurrentUser(): Promise<void> {
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<void> {