Files
glchat/internal/gateway/gateway_test.go
T
grendervill 05409d0914 fix(gateway): смена пароля не закрывает сессию текущего устройства
ChangePassword отзывает остальные сессии, но обработчик закрывал соединения
всех устройств пользователя — включая то, с которого пароль сменили: страница
теряла шлюз в момент успешного ответа, и подтверждение «Пароль изменён» не
показывалось (пункт 12 матрицы 11.6, меняли пароль через интерфейс).

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

Тест: TestInvalidateUserExceptKeepsCurrentSession (второе устройство получает
INVALID_SESSION, текущее продолжает отвечать на heartbeat).
2026-09-22 21:58:23 +03:00

562 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway_test
import (
"context"
"encoding/json"
"log/slog"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/permissions"
"glchat/internal/store"
)
type fixture struct {
service *gateway.Service
store *store.Store
auth *auth.Service
server *httptest.Server
}
func newFixture(t *testing.T) *fixture {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
st := store.New(db)
logger := slog.New(slog.DiscardHandler)
cfg := config.Config{
Domain: "gl.test",
SessionPepper: "gateway-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
}
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
t.Fatalf("auth.New: %v", err)
}
// Главный сервер с ролями и общей комнатой создаёт bootstrap.
if _, err := bootstrap.New(st, authService, logger).Run(ctx, bootstrap.Options{
Email: "owner@example.com", Password: "correct-horse-battery",
}); err != nil {
t.Fatalf("bootstrap: %v", err)
}
service := gateway.New(st, authService, gateway.NewSnapshot(st, nil), logger, []string{"https://gl.test"})
server := httptest.NewServer(service.Handler())
t.Cleanup(server.Close)
return &fixture{service: service, store: st, auth: authService, server: server}
}
// dial подключается к Gateway и читает HELLO.
func (f *fixture) dial(t *testing.T) (*websocket.Conn, gateway.Envelope) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(f.server.URL, "http")
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: map[string][]string{"Origin": {"https://gl.test"}},
})
if err != nil {
t.Fatalf("dial gateway: %v", err)
}
t.Cleanup(func() { _ = conn.CloseNow() })
envelope := readEnvelope(t, conn)
if envelope.Op != gateway.OpHello {
t.Fatalf("first frame op = %d, want HELLO (%d)", envelope.Op, gateway.OpHello)
}
return conn, envelope
}
func readEnvelope(t *testing.T, conn *websocket.Conn) gateway.Envelope {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read frame: %v", err)
}
var envelope gateway.Envelope
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode frame: %v (raw: %s)", err, data)
}
return envelope
}
func send(t *testing.T, conn *websocket.Conn, op int, payload any) {
t.Helper()
body, err := json.Marshal(map[string]any{"op": op, "d": payload})
if err != nil {
t.Fatalf("marshal frame: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := conn.Write(ctx, websocket.MessageText, body); err != nil {
t.Fatalf("write frame: %v", err)
}
}
// joinMainGuildForTest добавляет пользователя в главный сервер: в продакшене
// это делает принятие приглашения.
func joinMainGuildForTest(t *testing.T, f *fixture, token string) {
t.Helper()
ctx := context.Background()
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
guild, err := f.store.GetAdminGuild(ctx)
if err != nil {
t.Fatalf("GetAdminGuild: %v", err)
}
defaultRole, err := f.store.DefaultRole(ctx, guild.ID)
if err != nil {
t.Fatalf("DefaultRole: %v", err)
}
if _, err := f.store.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil {
t.Fatalf("AddGuildMember: %v", err)
}
if err := f.store.AssignRole(ctx, guild.ID, user.ID, defaultRole.ID); err != nil {
t.Fatalf("AssignRole: %v", err)
}
}
func registerUser(t *testing.T, f *fixture, username, email string) string {
t.Helper()
_, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{
Username: username, Email: email, Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("register %s: %v", username, err)
}
return token
}
func TestHelloAndReady(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "gateway_user", "gateway@example.com")
// Автовступления больше нет: в сервер вступаем явно, как по приглашению.
joinMainGuildForTest(t, f, token)
conn, hello := f.dial(t)
var helloPayload struct {
HeartbeatMS int `json:"heartbeat_interval_ms"`
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(hello.D, &helloPayload); err != nil {
t.Fatalf("decode HELLO: %v", err)
}
if helloPayload.HeartbeatMS != int(gateway.HeartbeatInterval.Milliseconds()) {
t.Fatalf("heartbeat interval = %d", helloPayload.HeartbeatMS)
}
if helloPayload.SessionID == "" {
t.Fatal("HELLO must carry a session id")
}
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
ready := readEnvelope(t, conn)
if ready.Op != gateway.OpDispatch || ready.T != "READY" {
t.Fatalf("frame = op:%d t:%q, want DISPATCH/READY", ready.Op, ready.T)
}
if ready.S <= 0 {
t.Fatalf("READY sequence = %d, want > 0", ready.S)
}
var snapshot gateway.Ready
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if snapshot.User.Username != "gateway_user" {
t.Fatalf("READY user = %q", snapshot.User.Username)
}
if len(snapshot.Guilds) != 1 {
t.Fatalf("user must see the guild they joined, got %d", len(snapshot.Guilds))
}
guild := snapshot.Guilds[0]
if len(guild.Roles) != 2 {
t.Fatalf("main guild roles = %d, want 2", len(guild.Roles))
}
if len(guild.Channels) != 1 || guild.Channels[0].Name != "общий" {
t.Fatalf("unexpected channels: %+v", guild.Channels)
}
if !guild.Channels[0].CanSend {
t.Fatal("default role must allow sending messages")
}
if len(guild.MemberIDs) == 0 {
t.Fatal("READY must include guild members")
}
if len(guild.MyRoles) != 1 {
t.Fatalf("new user must have the default role, got %v", guild.MyRoles)
}
if snapshot.HeartbeatMS != int(gateway.HeartbeatInterval.Milliseconds()) {
t.Fatalf("READY heartbeat interval = %d", snapshot.HeartbeatMS)
}
}
// TestReadyCarriesMyPermissions проверяет, что снапшот несёт права участника:
// пустой `my_permissions` выключал композер и управление сервером у всех,
// включая владельца (AGENT.md 11.6).
func TestReadyCarriesMyPermissions(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "perm_user", "perm@example.com")
joinMainGuildForTest(t, f, token)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
perms := snapshot.Guilds[0].MyPerms
if len(perms) == 0 {
t.Fatal("READY must carry my_permissions of the default role")
}
// Роль @user даёт эти права каждому участнику (AGENT.md 6.3).
for _, want := range []string{"VIEW_CHANNEL", "SEND_MESSAGES"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions = %v, want %s", perms, want)
}
}
if !snapshot.Guilds[0].Channels[0].CanSend {
t.Fatal("my_permissions must agree with channel can_send")
}
}
// TestReadyGivesInstanceAdminAllPermissions: у инстанс-админа права
// максимальные даже на чужом сервере, где он не участник (AGENT.md 7.19).
func TestReadyGivesInstanceAdminAllPermissions(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "admin_user", "admin@example.com")
// Инстанс-админ — участник главного сервера: список серверов берётся из
// членства, а права на них максимальные.
joinMainGuildForTest(t, f, token)
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
t.Fatalf("ResolveSession: %v", err)
}
if err := f.store.SetInstanceAdmin(ctx, user.ID, true); err != nil {
t.Fatalf("SetInstanceAdmin: %v", err)
}
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
var snapshot gateway.Ready
if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if len(snapshot.Guilds) != 1 {
t.Fatalf("instance admin must see the guild, got %d", len(snapshot.Guilds))
}
perms := snapshot.Guilds[0].MyPerms
for _, want := range []string{"ADMINISTRATOR", "SEND_MESSAGES", "MANAGE_ROLES", "BAN_MEMBERS"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions инстанс-админа = %v, want %s", perms, want)
}
}
}
func TestReadyHidesPrivateChannels(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
// Приватная комната: @user не видит.
mainGuild, err := f.store.GetAdminGuild(ctx)
if err != nil {
t.Fatalf("GetAdminGuild: %v", err)
}
defaultRole, err := f.store.DefaultRole(ctx, mainGuild.ID)
if err != nil {
t.Fatalf("DefaultRole: %v", err)
}
private, err := f.store.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &mainGuild.ID, Type: store.ChannelText, Name: "приватный",
})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := f.store.SetChannelOverride(ctx, store.ChannelOverride{
ChannelID: private.ID, TargetType: "role", TargetID: defaultRole.ID,
Deny: uint64(permissions.ViewChannel),
}); err != nil {
t.Fatalf("SetChannelOverride: %v", err)
}
token := registerUser(t, f, "private_user", "private@example.com")
joinMainGuildForTest(t, f, token)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
ready := readEnvelope(t, conn)
var snapshot gateway.Ready
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
for _, channel := range snapshot.Guilds[0].Channels {
if channel.Name == "приватный" {
t.Fatal("private channel must not appear in READY")
}
}
}
func TestIdentifyWithInvalidToken(t *testing.T) {
f := newFixture(t)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": "not-a-real-token"})
response := readEnvelope(t, conn)
if response.Op != gateway.OpInvalidSess {
t.Fatalf("op = %d, want INVALID_SESSION (%d)", response.Op, gateway.OpInvalidSess)
}
}
// TestIdentifyWithSessionCookie проверяет браузерный вход: токен в IDENTIFY
// не передаётся, сессия берётся из cookie рукопожатия (AGENT.md 8.1).
func TestIdentifyWithSessionCookie(t *testing.T) {
f := newFixture(t)
_, token, session, err := f.auth.Register(context.Background(), auth.RegisterInput{
Username: "cookie_user", Email: "cookie@example.com", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("register: %v", err)
}
if session == nil {
t.Fatal("register returned no session")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url := "ws" + strings.TrimPrefix(f.server.URL, "http")
conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{
HTTPHeader: map[string][]string{
"Origin": {"https://gl.test"},
"Cookie": {gateway.SessionCookieName + "=" + token},
},
})
if err != nil {
t.Fatalf("dial with cookie: %v", err)
}
t.Cleanup(func() { _ = conn.CloseNow() })
if hello := readEnvelope(t, conn); hello.Op != gateway.OpHello {
t.Fatalf("first frame op = %d, want HELLO", hello.Op)
}
// IDENTIFY без токена: сервер доверяет cookie.
send(t, conn, gateway.OpIdentify, map[string]any{})
ready := readEnvelope(t, conn)
if ready.T != "READY" {
t.Fatalf("frame = %q, want READY", ready.T)
}
var snapshot gateway.Ready
if err := json.Unmarshal(ready.D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
if snapshot.User.Username != "cookie_user" {
t.Fatalf("READY user = %q, want cookie_user", snapshot.User.Username)
}
}
func TestIdentifyWithoutTokenOrCookie(t *testing.T) {
f := newFixture(t)
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{})
response := readEnvelope(t, conn)
if response.Op != gateway.OpInvalidSess {
t.Fatalf("op = %d, want INVALID_SESSION", response.Op)
}
}
func TestHeartbeatAck(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com")
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, conn); ready.T != "READY" {
t.Fatalf("expected READY, got %q", ready.T)
}
send(t, conn, gateway.OpHeartbeat, nil)
ack := readEnvelope(t, conn)
if ack.Op != gateway.OpHeartbeatAck {
t.Fatalf("op = %d, want HEARTBEAT_ACK (%d)", ack.Op, gateway.OpHeartbeatAck)
}
}
func TestDispatchReachesConnectedClient(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "dispatch_user", "dispatch@example.com")
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, conn); ready.T != "READY" {
t.Fatalf("expected READY, got %q", ready.T)
}
f.service.Dispatch("MEMBER_UPDATE", map[string]any{"nickname": "новый ник"})
event := readEnvelope(t, conn)
if event.T != "MEMBER_UPDATE" {
t.Fatalf("event = %q, want MEMBER_UPDATE", event.T)
}
if event.S <= 1 {
t.Fatalf("dispatch sequence = %d, want greater than READY", event.S)
}
}
// 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")
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
ready := readEnvelope(t, conn)
// Пока клиент отключён, приходят события.
_ = conn.Close(websocket.StatusNormalClosure, "test disconnect")
deadline := time.Now().Add(2 * time.Second)
for f.service.ActiveSessions() > 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
f.service.Dispatch("CHANNEL_CREATE", map[string]any{"name": "новый"})
// Переподключение с RESUME по последней известной последовательности.
second, _ := f.dial(t)
send(t, second, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S})
replayed := readEnvelope(t, second)
if replayed.T != "CHANNEL_CREATE" {
t.Fatalf("resume replay = %q, want CHANNEL_CREATE", replayed.T)
}
// RESUME с неизвестной последовательностью: INVALID_SESSION с признаком
// resumable и следом полный READY (AGENT.md 8.3).
third, _ := f.dial(t)
send(t, third, gateway.OpResume, map[string]any{"token": token, "resume_seq": ready.S + 10000})
invalid := readEnvelope(t, third)
if invalid.Op != gateway.OpInvalidSess {
t.Fatalf("stale resume op = %d, want INVALID_SESSION", invalid.Op)
}
var invalidPayload struct {
Resumable bool `json:"resumable"`
}
if err := json.Unmarshal(invalid.D, &invalidPayload); err != nil {
t.Fatalf("decode INVALID_SESSION: %v", err)
}
if !invalidPayload.Resumable {
t.Fatal("resume failure must be marked resumable: the client keeps the session")
}
full := readEnvelope(t, third)
if full.T != "READY" {
t.Fatalf("stale resume = %q, want full READY", full.T)
}
}
// TestInvalidateUserClosesConnections проверяет отзыв сессий: после
// logout-all, смены пароля или действий администратора соединения клиента
// должны закрыться с INVALID_SESSION, а не ждать следующего запроса
// (AGENT.md 11.6). Отдельно проверяется, что чужого пользователя это не задевает.
func TestInvalidateUserClosesConnections(t *testing.T) {
f := newFixture(t)
token := registerUser(t, f, "invalidate_user", "invalidate@example.com")
otherToken := registerUser(t, f, "invalidate_other", "invalidate-other@example.com")
conn, _ := f.dial(t)
send(t, conn, gateway.OpIdentify, map[string]any{"token": token})
if ready := readEnvelope(t, conn); 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 other client, got %q", ready.T)
}
user, err := f.auth.UserByEmail(context.Background(), "invalidate@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
f.service.InvalidateUser(user.ID, "logout_all")
invalid := readEnvelope(t, conn)
if invalid.Op != gateway.OpInvalidSess {
t.Fatalf("op = %d, want INVALID_SESSION (%d)", invalid.Op, gateway.OpInvalidSess)
}
// Второй пользователь продолжает работать: heartbeat отвечает.
send(t, otherConn, gateway.OpHeartbeat, nil)
if ack := readEnvelope(t, otherConn); ack.Op != gateway.OpHeartbeatAck {
t.Fatalf("other client op = %d, want HEARTBEAT_ACK", ack.Op)
}
}