Compare commits
3 Commits
a7b4e3318e
...
eeececaff8
| Author | SHA1 | Date | |
|---|---|---|---|
| eeececaff8 | |||
| dcafb10046 | |||
| 05409d0914 |
@@ -396,12 +396,29 @@ func (s *Service) SendToUser(userID uint64, event string, payload any) {
|
||||
// (logout-all, смена пароля, действия администратора) — иначе второе
|
||||
// устройство остаётся «в приложении» до следующего запроса (AGENT.md 11.6).
|
||||
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()
|
||||
targets := make([]*clientSession, 0, 2)
|
||||
for _, session := range s.sessions {
|
||||
if session.userID == userID {
|
||||
targets = append(targets, session)
|
||||
if session.userID != userID {
|
||||
continue
|
||||
}
|
||||
// Соединение опознаём по хэшу токена сессии: у каждой сессии свой буфер
|
||||
// RESUME, он же хранит владельца (AGENT.md 8.3).
|
||||
if keepTokenHash != "" && session.buffer != nil && session.buffer.tokenHash == keepTokenHash {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, session)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, session := range targets {
|
||||
|
||||
@@ -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) {
|
||||
f := newFixture(t)
|
||||
token := registerUser(t, f, "resume_user", "resume@example.com")
|
||||
|
||||
@@ -325,7 +325,7 @@ func (s *Service) register(session *clientSession, tokenHash string) {
|
||||
s.sessions[session.id] = session
|
||||
buffer, ok := s.buffers[tokenHash]
|
||||
if !ok {
|
||||
buffer = newResumeBuffer(ResumeBufferSize)
|
||||
buffer = newResumeBuffer(ResumeBufferSize, tokenHash)
|
||||
s.buffers[tokenHash] = buffer
|
||||
}
|
||||
// Владелец буфера нужен, чтобы доставлять адресные события (SendToUser)
|
||||
@@ -347,7 +347,7 @@ func (s *Service) bufferFor(tokenHash string) *resumeBuffer {
|
||||
if buffer, ok := s.buffers[tokenHash]; ok {
|
||||
return buffer
|
||||
}
|
||||
buffer := newResumeBuffer(ResumeBufferSize)
|
||||
buffer := newResumeBuffer(ResumeBufferSize, tokenHash)
|
||||
s.buffers[tokenHash] = buffer
|
||||
return buffer
|
||||
}
|
||||
@@ -406,6 +406,9 @@ func newSessionID() string {
|
||||
// resumeBuffer хранит последние события для RESUME (AGENT.md 8.3).
|
||||
type resumeBuffer struct {
|
||||
mu sync.Mutex
|
||||
// tokenHash — сессия пользователя, которой принадлежит буфер: по нему
|
||||
// адресные отзывы отличают текущее соединение от остальных.
|
||||
tokenHash string
|
||||
owner uint64
|
||||
limit int
|
||||
items []bufferedEvent
|
||||
@@ -417,8 +420,8 @@ type bufferedEvent struct {
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func newResumeBuffer(limit int) *resumeBuffer {
|
||||
return &resumeBuffer{limit: limit, updated: time.Now()}
|
||||
func newResumeBuffer(limit int, tokenHash string) *resumeBuffer {
|
||||
return &resumeBuffer{limit: limit, tokenHash: tokenHash, updated: time.Now()}
|
||||
}
|
||||
|
||||
func (b *resumeBuffer) append(seq int64, payload []byte) {
|
||||
|
||||
@@ -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 {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Смена пароля отзывает остальные сессии: соединения закрываем сразу.
|
||||
// Смена пароля отзывает остальные сессии: их соединения закрываем сразу,
|
||||
// а устройство, с которого пароль сменили, продолжает работать
|
||||
// (AGENT.md 7.1, 11.6).
|
||||
if s.gateway != nil {
|
||||
s.gateway.InvalidateUser(user.ID, "password_changed")
|
||||
s.gateway.InvalidateUserExcept(user.ID, session.TokenHash, "password_changed")
|
||||
}
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
@@ -472,18 +472,22 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
|
||||
await expect(memberRow).toHaveCount(0, { timeout: 20_000 });
|
||||
|
||||
// Возврат в сервер: он на время открывается — приглашения тратят
|
||||
// суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе.
|
||||
// Плитка сервера в рейке B при этом не возвращается без перезагрузки:
|
||||
// по GUILD_CREATE клиент берёт список через fetchQuery и получает свежий
|
||||
// кэш myGuilds без нового сервера (web/src/stores/gateway.ts,
|
||||
// addGuildFromRest) — дефект описан в отчёте, поэтому состав проверяем
|
||||
// по ответу сервера.
|
||||
// суточную квоту 10/24ч (AGENT.md 8.6). Участник снова в составе, и
|
||||
// плитка сервера возвращается в рейку B без перезагрузки: по GUILD_CREATE
|
||||
// клиент перечитывает список серверов (web/src/stores/gateway.ts,
|
||||
// addGuildFromRest), а не берёт устаревший кэш myGuilds.
|
||||
await setPublic(ownerApi, fixture.guildId, true);
|
||||
const rejoin = await memberApi.post(`${API}/guilds/${fixture.guildId}/join`);
|
||||
expect(rejoin.ok(), `возврат в сервер: ${await rejoin.text()}`).toBeTruthy();
|
||||
await expect
|
||||
.poll(async () => guildMemberIds(ownerApi, fixture?.guildId ?? ''), { timeout: 20_000 })
|
||||
.toContain(member.id);
|
||||
await expect(
|
||||
memberSession.page.getByRole('link', {
|
||||
name: `Открыть сервер ${fixture.guildName}`,
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await setPublic(ownerApi, fixture.guildId, false);
|
||||
|
||||
// Бан: участник снова исключён, попал в список банов и не может вернуться
|
||||
@@ -610,25 +614,17 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
|
||||
// Тест идёт последним: 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 и
|
||||
// уводит устройство на экран входа (AGENT.md 11.6).
|
||||
// сервер шлёт INVALID_SESSION, клиент сразу уходит на экран входа, даже
|
||||
// когда открыт сервер (AGENT.md 11.6).
|
||||
const ownerApi = await apiAs(playwright, owner.state);
|
||||
const memberApi = await apiAs(playwright, member.state);
|
||||
const sessions: Session[] = [];
|
||||
let fixture: GuildFixture | null = null;
|
||||
// Устройство, сменившее пароль: с него возвращаем исходный в finally, чтобы
|
||||
// падение теста не оставило постоянный пробный аккаунт с временным паролем.
|
||||
let passwordDevice: Page | null = null;
|
||||
let passwordChanged = false;
|
||||
|
||||
try {
|
||||
fixture = await prepareGuild(ownerApi, memberApi);
|
||||
@@ -655,29 +651,23 @@ test.describe('realtime: пункты 8–13 (чат, состояния, мод
|
||||
sessions.push(third, fourth);
|
||||
await openWorkspace(third.page, fixture);
|
||||
await openWorkspace(fourth.page, fixture);
|
||||
passwordDevice = third.page;
|
||||
await changePassword(third.page, member.password, MEMBER_TEMP_PASSWORD);
|
||||
passwordChanged = true;
|
||||
const revoked = await fourth.page.evaluate(async () => {
|
||||
const response = await fetch('/api/v1/users/@me', { credentials: 'same-origin' });
|
||||
return response.status;
|
||||
});
|
||||
expect(revoked, 'сессия второго устройства отозвана сменой пароля').toBe(401);
|
||||
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 {
|
||||
if (passwordChanged && passwordDevice !== null) {
|
||||
const restored = await restoreMemberPassword(passwordDevice, member.password);
|
||||
expect(
|
||||
restored,
|
||||
`пароль пробного аккаунта ${MEMBER_LOGIN.username} возвращён (${restored})`,
|
||||
).toBe(200);
|
||||
}
|
||||
for (const device of sessions) {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 открывает браузерный контекст на готовой сессии аккаунта. */
|
||||
async function sessionFrom(browser: Browser, state: StorageState): Promise<Session> {
|
||||
const context = await browser.newContext({ storageState: state });
|
||||
|
||||
@@ -7,7 +7,10 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
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 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
Reference in New Issue
Block a user