Compare commits

...

3 Commits

Author SHA1 Message Date
grendervill eeececaff8 test(e2e): таймаут прогона на живом стенде задаётся переменной
Подготовка файла (beforeAll) входит в пробные аккаунты, а лимит — 5 входов в
минуту на IP (AGENT.md 8.6): на живом стенде подготовка не укладывалась в
180 секунд, и весь файл падал в beforeAll ещё до тестов. Значение по умолчанию
не меняется, для прогонов на стенде поднимается GLCHAT_E2E_TIMEOUT_MS.
2026-09-22 22:28:10 +03:00
grendervill dcafb10046 test(e2e): матрица 11.6 — пункт 12 без fixme и возврат сервера в пункте 11
Пункт 12 включён: клиент уходит на экран входа при отзыве сессий, в том числе
когда открыт сервер (дефект исправлен в 751ecc6), а смена пароля на сервере
больше не рвёт соединение текущего устройства (05409d0).

Пункт 11 теперь проверяет то, ради чего он написан: после кика и повторного
входа плитка сервера возвращается в рейку без перезагрузки (правка клиента в
ea1b4a7). Возврат пароля пробного участника перенесён в finally: падение теста
не оставляет постоянный аккаунт с временным паролем, а неудачный возврат
сообщается явной проверкой.

Прогон: 13 passed (2,9 мин), лог — build/e2e-realtime-run4.log.
2026-09-22 22:28:10 +03:00
grendervill 05409d0914 fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения
всех устройств пользователя — включая то, с которого пароль сменили: страница
теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не
показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс).

Добавлен InvalidateUserExcept: текущее соединение опознаётся по хэшу токена
сессии, который теперь хранит и буфер RESUME. Для logout-all, бана и
админского сброса поведение прежнее — закрываются все соединения.

Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает
INVALID_SESSION, текущее продолжает отвечать на heartbeat).
2026-09-22 21:58:23 +03:00
6 changed files with 135 additions and 49 deletions
+19 -2
View File
@@ -396,12 +396,29 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
// (logout-all, смена пароля, действия администратора) — иначе второе // (logout-all, смена пароля, действия администратора) — иначе второе
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6). // устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
func (s *Service) InvalidateUser(userID uint64, reason string) { func (s *Service) InvalidateUser(userID uint64, reason string) {
s.invalidateUser(userID, "", reason)
}
// InvalidateUserExcept закрывает соединения пользователя, кроме текущего:
// смена пароля отзывает остальные сессии, но устройство, с которого её
// сменили, продолжает работать (AGENT.md 7.1, 11.6).
func (s *Service) InvalidateUserExcept(userID uint64, keepTokenHash, reason string) {
s.invalidateUser(userID, keepTokenHash, reason)
}
func (s *Service) invalidateUser(userID uint64, keepTokenHash, reason string) {
s.mu.Lock() s.mu.Lock()
targets := make([]*clientSession, 0, 2) targets := make([]*clientSession, 0, 2)
for _, session := range s.sessions { for _, session := range s.sessions {
if session.userID == userID { if session.userID != userID {
targets = append(targets, session) continue
} }
// Соединение опознаём по хэшу токена сессии: у каждой сессии свой буфер
// RESUME, он же хранит владельца (AGENT.md 8.3).
if keepTokenHash != "" && session.buffer != nil && session.buffer.tokenHash == keepTokenHash {
continue
}
targets = append(targets, session)
} }
s.mu.Unlock() s.mu.Unlock()
for _, session := range targets { for _, session := range targets {
+50
View File
@@ -424,6 +424,56 @@ func TestDispatchReachesConnectedClient(t *testing.T) {
} }
} }
// TestInvalidateUserExceptKeepsCurrentSession: смена пароля отзывает остальные
// сессии, но соединение устройства, с которого её сменили, остаётся живым,
// иначе пользователь терял бы интерфейс в момент успешной смены (AGENT.md 7.1).
func TestInvalidateUserExceptKeepsCurrentSession(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "except_user", "except@example.com")
// Вторая сессия того же пользователя: вход тем же паролем с другого
// «устройства» (в тесте — второй токен).
user, currentSession, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
email, err := f.auth.Email(ctx, user.ID)
if err != nil {
t.Fatalf("Email: %v", err)
}
_, otherToken, otherSession, err := f.auth.Login(ctx, auth.LoginInput{
Email: email, Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("Login: %v", err)
}
currentConn, _ := f.dial(t)
send(t, currentConn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, currentConn); ready.T != "READY" {
t.Fatalf("expected READY, got %q", ready.T)
}
otherConn, _ := f.dial(t)
send(t, otherConn, gateway.OpIdentify, map[string]any{"token": otherToken})
if ready := readEnvelope(t, otherConn); ready.T != "READY" {
t.Fatalf("expected READY for the second device, got %q", ready.T)
}
f.service.InvalidateUserExcept(user.ID, currentSession.TokenHash, "password_changed")
// Второе устройство получает INVALID_SESSION...
invalid := readEnvelope(t, otherConn)
if invalid.Op != gateway.OpInvalidSess {
t.Fatalf("other device op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
}
// ...а текущее продолжает работать: heartbeat отвечает.
send(t, currentConn, gateway.OpHeartbeat, nil)
if ack := readEnvelope(t, currentConn); ack.Op != gateway.OpHeartbeatAck {
t.Fatalf("current device op = %d, want HEARTBEAT_ACK", ack.Op)
}
_ = otherSession
}
func TestResumeReplaysMissedEvents(t *testing.T) { func TestResumeReplaysMissedEvents(t *testing.T) {
f := newFixture(t) f := newFixture(t)
token := registerUser(t, f, "resume_user", "resume@example.com") token := registerUser(t, f, "resume_user", "resume@example.com")
+12 -9
View File
@@ -325,7 +325,7 @@ func (s *Service) register(session *clientSession, tokenHash string) {
s.sessions[session.id] = session s.sessions[session.id] = session
buffer, ok := s.buffers[tokenHash] buffer, ok := s.buffers[tokenHash]
if !ok { if !ok {
buffer = newResumeBuffer(ResumeBufferSize) buffer = newResumeBuffer(ResumeBufferSize, tokenHash)
s.buffers[tokenHash] = buffer s.buffers[tokenHash] = buffer
} }
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser) // Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
@@ -347,7 +347,7 @@ func (s *Service) bufferFor(tokenHash string) *resumeBuffer {
if buffer, ok := s.buffers[tokenHash]; ok { if buffer, ok := s.buffers[tokenHash]; ok {
return buffer return buffer
} }
buffer := newResumeBuffer(ResumeBufferSize) buffer := newResumeBuffer(ResumeBufferSize, tokenHash)
s.buffers[tokenHash] = buffer s.buffers[tokenHash] = buffer
return buffer return buffer
} }
@@ -405,11 +405,14 @@ func newSessionID() string {
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3). // resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
type resumeBuffer struct { type resumeBuffer struct {
mu sync.Mutex mu sync.Mutex
owner uint64 // tokenHash — сессия пользователя, которой принадлежит буфер: по нему
limit int // адресные отзывы отличают текущее соединение от остальных.
items []bufferedEvent tokenHash string
updated time.Time owner uint64
limit int
items []bufferedEvent
updated time.Time
} }
type bufferedEvent struct { type bufferedEvent struct {
@@ -417,8 +420,8 @@ type bufferedEvent struct {
payload []byte payload []byte
} }
func newResumeBuffer(limit int) *resumeBuffer { func newResumeBuffer(limit int, tokenHash string) *resumeBuffer {
return &resumeBuffer{limit: limit, updated: time.Now()} return &resumeBuffer{limit: limit, tokenHash: tokenHash, updated: time.Now()}
} }
func (b *resumeBuffer) append(seq int64, payload []byte) { func (b *resumeBuffer) append(seq int64, payload []byte) {
+4 -2
View File
@@ -270,9 +270,11 @@ func (s *Server) registerUserRoutes(api huma.API) {
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil { if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
return nil, humaError(err) return nil, humaError(err)
} }
// Смена пароля отзывает остальные сессии: соединения закрываем сразу. // Смена пароля отзывает остальные сессии: их соединения закрываем сразу,
// а устройство, с которого пароль сменили, продолжает работать
// (AGENT.md 7.1, 11.6).
if s.gateway != nil { if s.gateway != nil {
s.gateway.InvalidateUser(user.ID, "password_changed") s.gateway.InvalidateUserExcept(user.ID, session.TokenHash, "password_changed")
} }
return newOKOutput(), nil return newOKOutput(), nil
}) })
+46 -35
View File
@@ -472,18 +472,22 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
await expect(memberRow).toHaveCount(0, { timeout: 20_000 }); await expect(memberRow).toHaveCount(0, { timeout: 20_000 });
// Возврат в сервер: он на время открывается — приглашения тратят // Возврат в сервер: он на время открывается — приглашения тратят
// суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе. // суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе, и
// Плитка сервера в рейке B при этом не возвращается без перезагрузки: // плитка сервера возвращается в рейку B без перезагрузки: по GUILD_CREATE
// по GUILD_CREATE клиент берёт список через fetchQuery и получает свежий // клиент перечитывает список серверов (web/src/stores/gateway.ts,
// кэш myGuilds без нового сервера (web/src/stores/gateway.ts, // addGuildFromRest), а не берёт устаревший кэш myGuilds.
// addGuildFromRest) — дефект описан в отчёте, поэтому состав проверяем
// по ответу сервера.
await setPublic(ownerApi, fixture.guildId, true); await setPublic(ownerApi, fixture.guildId, true);
const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`); const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`);
expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy(); expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy();
await expect await expect
.poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 }) .poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 })
.toContain(member.id); .toContain(member.id);
await expect(
memberSession.page.getByRole('link', {
name: `Открыть сервер ${fixture.guildName}`,
exact: true,
}),
).toBeVisible({ timeout: 20_000 });
await setPublic(ownerApi, fixture.guildId, false); await setPublic(ownerApi, fixture.guildId, false);
// Бан: участник снова исключён, попал в список банов и не может вернуться // Бан: участник снова исключён, попал в список банов и не может вернуться
@@ -610,25 +614,17 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
// Тест идёт последним: logout-all и смена пароля отзывают подготовленные // Тест идёт последним: logout-all и смена пароля отзывают подготовленные
// сессии файла, поэтому остальным тестам они должны остаться живыми. // сессии файла, поэтому остальным тестам они должны остаться живыми.
// //
// Дефект: сервер отзывает сессии и присылает второму устройству
// {"op":4,"d":{"reason":"password_changed","resumable":false}} + CLOSE 1000
// (проверено перехватом WS на стенде), но устройство, находящееся внутри
// сервера (/app/<сервер>/<комната>), остаётся на месте: запроса
// GET /users/@me после отзыва не происходит, экран входа не появляется.
// Скрипт build/verify-session-invalidation.mjs показывает уход на /login,
// когда второе устройство находится на /app без открытого сервера, поэтому
// дело в ветке клиента, которая срабатывает при пустом снапшоте серверов.
test.fixme(
true,
'второе устройство с открытым сервером не уходит на /login после отзыва сессии (профиль не перечитывается)',
);
// Оба действия подтверждаются на втором устройстве без перезагрузки — // Оба действия подтверждаются на втором устройстве без перезагрузки —
// сервер шлёт INVALID_SESSION, клиент перечитывает профиль, получает 401 и // сервер шлёт INVALID_SESSION, клиент сразу уходит на экран входа, даже
// уводит устройство на экран входа (AGENT.md 11.6). // когда открыт сервер (AGENT.md 11.6).
const ownerApi = await apiAs(playwright, owner.state); const ownerApi = await apiAs(playwright, owner.state);
const memberApi = await apiAs(playwright, member.state); const memberApi = await apiAs(playwright, member.state);
const sessions: Session[] = []; const sessions: Session[] = [];
let fixture: GuildFixture | null = null; let fixture: GuildFixture | null = null;
// Устройство, сменившее пароль: с него возвращаем исходный в finally, чтобы
// падение теста не оставило постоянный пробный аккаунт с временным паролем.
let passwordDevice: Page | null = null;
let passwordChanged = false;
try { try {
fixture = await prepareGuild(ownerApi, memberApi); fixture = await prepareGuild(ownerApi, memberApi);
@@ -655,29 +651,23 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
sessions.push(third, fourth); sessions.push(third, fourth);
await openWorkspace(third.page, fixture); await openWorkspace(third.page, fixture);
await openWorkspace(fourth.page, fixture); await openWorkspace(fourth.page, fixture);
passwordDevice = third.page;
await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD); await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD);
passwordChanged = true;
const revoked = await fourth.page.evaluate(async () => { const revoked = await fourth.page.evaluate(async () => {
const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' }); const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' });
return response.status; return response.status;
}); });
expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401); expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401);
await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 }); await expect(fourth.page).toHaveURL(/\/login/u, { timeout: 30_000 });
// Пароль возвращаем исходный из уцелевшей сессии устройства 3.
const restored = await third.page.evaluate(async (password: string) => {
const response = await fetch('/api/v1/users/@me/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
current_password: 'Rt-Probe-Member-Temp-9x',
new_password: password,
}),
});
return response.status;
}, member.password);
expect(restored, 'пароль пробного аккаунта возвращён').toBe(200);
} finally { } finally {
if (passwordChanged && passwordDevice !== null) {
const restored = await restoreMemberPassword(passwordDevice, member.password);
expect(
restored,
`пароль пробного аккаунта ${MEMBER_LOGIN.username} возвращён (${restored})`,
).toBe(200);
}
for (const device of sessions) { for (const device of sessions) {
await device.context.close(); await device.context.close();
} }
@@ -938,6 +928,27 @@ async function changePassword(page: Page, current: string, next: string): Promis
await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 }); await expect(content.getByRole('status')).toContainText('Пароль изменён', { timeout: 20_000 });
} }
/**
* restoreMemberPassword возвращает пробному участнику исходный пароль с
* устройства, которое его сменило. Возврат идёт из finally: если он не удался,
* постоянный аккаунт останется с временным паролем и следующие прогоны файла
* не смогут войти — поэтому тест обязан сообщить об этом явно.
*/
async function restoreMemberPassword(page: Page, password: string): Promise<number> {
return page.evaluate(
async (payload: { current: string; next: string }) => {
const response = await fetch('/api/v1/users/@me/password', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ current_password: payload.current, new_password: payload.next }),
});
return response.status;
},
{ current: MEMBER_TEMP_PASSWORD, next: password },
);
}
/** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */ /** sessionFrom открывает браузерный контекст на готовой сессии аккаунта. */
async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> { async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> {
const context = await browser.newContext({ storageState: state }); const context = await browser.newContext({ storageState: state });
+4 -1
View File
@@ -7,7 +7,10 @@ import { defineConfig, devices } from '@playwright/test';
*/ */
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 180_000, // Подготовка файла входами в пробные аккаунты упирается в лимит 5 входов в
// минуту на IP (AGENT.md 8.6): на живом стенде `beforeAll` не успевает за
// 180 секунд. Переменная поднимает лимит для прогонов на стенде.
timeout: Number(process.env.GLCHAT_E2E_TIMEOUT_MS ?? 180_000),
expect: { timeout: 20_000 }, expect: { timeout: 20_000 },
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,