Files
glchat/internal/gateway/gateway_test.go
T
grendervill f26728113d feat(instance): сервер установщика больше не «главный»
Понятие главного сервера убрано из поведения: сервер, создаваемый установщиком,
обычный (is_main = false), автовступления в него нет, а в настройках инстанса
он остаётся лишь справочно (main_guild_id → AdminGuildID в ответе API).
Клиент от него больше не зависит: новичок начинает с пустым списком серверов.
2026-09-20 01:12:01 +03:00

407 lines
13 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"
"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)
}
}
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)
}
}
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)
}
}