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:
@@ -1864,6 +1864,13 @@ func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64
|
|||||||
if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin {
|
if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin {
|
||||||
return humaErrorStatus(http.StatusForbidden, "guild.invite_required", "this guild requires an invite")
|
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)
|
settings, err := s.store.InstanceSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return humaError(err)
|
return humaError(err)
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ func TestBanMemberFlow(t *testing.T) {
|
|||||||
t.Fatalf("код ошибки = %q", code)
|
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)
|
unban := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/bans/"+memberID, "", owner)
|
||||||
if unban.Code != http.StatusOK {
|
if unban.Code != http.StatusOK {
|
||||||
|
|||||||
+1
-1
@@ -2,5 +2,5 @@ dist
|
|||||||
node_modules
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
*.min.js
|
*.min.js
|
||||||
test-results
|
test-results*
|
||||||
playwright-report
|
playwright-report
|
||||||
|
|||||||
+15
-46
@@ -1,5 +1,5 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
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): десять участников в одной комнате
|
* Голосовая нагрузка (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 FIXTURE_PATH = process.env.GLCHAT_LOADTEST_FIXTURE ?? '../../build/loadtest-fixture.json';
|
||||||
const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10');
|
const PARTICIPANTS = Number(process.env.GLCHAT_VOICE_PARTICIPANTS ?? '10');
|
||||||
const EXPECT_SCREENSHARE = process.env.GLCHAT_EXPECT_SCREENSHARE === '1';
|
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 {
|
interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,41 +38,6 @@ function readFixture(): Fixture {
|
|||||||
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as 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 нажимает «Войти» в голосовой комнате и ждёт подключения. */
|
/** joinVoice нажимает «Войти» в голосовой комнате и ждёт подключения. */
|
||||||
async function joinVoice(page: Page): Promise<void> {
|
async function joinVoice(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId('voice-join');
|
const button = page.getByTestId('voice-join');
|
||||||
@@ -101,11 +64,17 @@ async function inboundAudioBytes(page: Page): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** inboundVideoStats отдаёт лучший входящий видеопоток: размер и кадры в секунду. */
|
interface VideoStats {
|
||||||
async function inboundVideoStats(
|
width: number;
|
||||||
page: Page,
|
height: number;
|
||||||
): Promise<{ width: number; height: number; fps: number }> {
|
fps: number;
|
||||||
return page.evaluate(async () => {
|
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[] };
|
const globalWindow = window as unknown as { __pcs?: RTCPeerConnection[] };
|
||||||
let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 };
|
let best = { width: 0, height: 0, fps: 0, decoded: 0, bytes: 0 };
|
||||||
for (const pc of globalWindow.__pcs ?? []) {
|
for (const pc of globalWindow.__pcs ?? []) {
|
||||||
@@ -209,15 +178,15 @@ test.describe('голосовая нагрузка: десять участни
|
|||||||
// декодирование 60 кадров в секунду меряет независимый подписчик —
|
// декодирование 60 кадров в секунду меряет независимый подписчик —
|
||||||
// build/verify-1080p60-browser.mjs (adaptive stream выключен).
|
// build/verify-1080p60-browser.mjs (adaptive stream выключен).
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => inboundVideoStats(sessions[0]!.page), {
|
.poll(async () => inboundVideoStats(sessions[0].page), {
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
intervals: [2000],
|
intervals: [2000],
|
||||||
})
|
})
|
||||||
.toMatchObject({ width: 1920, height: 1080 });
|
.toMatchObject({ width: 1920, height: 1080 });
|
||||||
const stats = await inboundVideoStats(sessions[0]!.page);
|
const stats = await inboundVideoStats(sessions[0].page);
|
||||||
console.log('входящее видео:', JSON.stringify(stats));
|
console.log('входящее видео:', JSON.stringify(stats));
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await inboundVideoStats(sessions[0]!.page)).bytes, {
|
.poll(async () => (await inboundVideoStats(sessions[0].page)).bytes, {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
intervals: [2000],
|
intervals: [2000],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,4 +42,10 @@ export default tseslint.config(
|
|||||||
files: ['eslint.config.js'],
|
files: ['eslint.config.js'],
|
||||||
extends: [tseslint.configs.disableTypeChecked],
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
},
|
},
|
||||||
|
// e2e-тесты печатают диагностику прогона (шаги, размеры потоков, fps),
|
||||||
|
// поэтому console.log в них разрешён: без него живой тест не отладить.
|
||||||
|
{
|
||||||
|
files: ['e2e/**/*.ts'],
|
||||||
|
rules: { 'no-console': 'off' },
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
// removeGuild сам выбирает первый оставшийся сервер (или пустой список).
|
// removeGuild сам выбирает первый оставшийся сервер (или пустой список).
|
||||||
session.removeGuild(guildId);
|
session.removeGuild(guildId);
|
||||||
void invalidateGuildQueries(guildId);
|
void invalidateGuildQueries(guildId);
|
||||||
|
// Снапшот и REST-кэш должны совпадать: иначе пустой стор подставит
|
||||||
|
// устаревший myGuilds и сервер вернётся в рейку.
|
||||||
|
void invalidateMyGuilds();
|
||||||
syncUrlWithSelection();
|
syncUrlWithSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -354,6 +357,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
session.reset();
|
session.reset();
|
||||||
messages.reset();
|
messages.reset();
|
||||||
useVoiceConnectionStore.getState().reset();
|
useVoiceConnectionStore.getState().reset();
|
||||||
|
void invalidateMyGuilds();
|
||||||
void invalidateCurrentUser();
|
void invalidateCurrentUser();
|
||||||
return;
|
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> {
|
async function invalidateGuildQueries(guildId: string): Promise<void> {
|
||||||
const [{ getQueryClient }, guilds] = await Promise.all([
|
const [{ getQueryClient }, guilds] = await Promise.all([
|
||||||
@@ -555,8 +568,10 @@ async function invalidateCurrentUser(): Promise<void> {
|
|||||||
import('@/lib/queryClient'),
|
import('@/lib/queryClient'),
|
||||||
import('@/api/users'),
|
import('@/api/users'),
|
||||||
]);
|
]);
|
||||||
// removeQueries вместо invalidate: сессия мертва, повторный запрос не нужен.
|
// Именно refetch, а не removeQueries: AuthGuard узнаёт о мёртвой сессии по
|
||||||
|
// 401 на запросе профиля и только тогда уводит на экран входа (AGENT.md 11.6).
|
||||||
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
|
getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
|
||||||
|
await getQueryClient().refetchQueries({ queryKey: currentUserQueryKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refetchCurrentUser(): Promise<void> {
|
async function refetchCurrentUser(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user