fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения всех устройств пользователя — включая то, с которого пароль сменили: страница теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс). Добавлен InvalidateUserExcept: текущее соединение опознаётся по хэшу токена сессии, который теперь хранит и буфер RESUME. Для logout-all, бана и админского сброса поведение прежнее — закрываются все соединения. Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает INVALID_SESSION, текущее продолжает отвечать на heartbeat).
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user