Files
glchat/internal/gateway/gateway_test.go
T
grendervill 33dc1fec96 feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс
По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
2026-09-20 01:11:04 +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.GetMainGuild(ctx)
if err != nil {
t.Fatalf("GetMainGuild: %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.GetMainGuild(ctx)
if err != nil {
t.Fatalf("GetMainGuild: %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)
}
}