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) } }