Files
tiennm99 81234b5ab9 fix(wsapi): stop a kicked token resuming into another seat, harden limits
A held seat now remembers which connection it waits for, so a token
from a kicked player is refused instead of landing in whoever took the
seat. A room answers every input left in its inbox when it exits,
disconnect notices no longer share the lossy inbox, quick-match no
longer leaves autoStart armed after a pairing that never started, only
lobby changes restart the idle window, and draining refuses new queue
entries.

Sockets that never send Hello close after ten seconds and the room
budget is charged per address, so one client cannot hold the global
caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide
rate limit with a suppressed counter. Responses carry nosniff,
frame-ancestors and referrer headers. Unicode spaces in a word become
spaces rather than vanishing, and blank-rendering letters are dropped
from names and chat. A resume into a lobby whose game ended during the
absence is replayed that seat's GameOver. Unknown payloads get
unknown_message.
2026-09-29 20:33:16 +07:00

534 lines
18 KiB
Go

package wsapi
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
)
// Lifecycle and abuse-surface guarantees at the edges of the room actor: what
// a seat id means after its seat is freed, what happens to inputs queued
// behind a room's last one, and what one address may hold.
// handlerRoom builds a room that is driven by direct handler calls, without
// its goroutine running, for the guarantees that live below the transport.
func handlerRoom(t *testing.T) *room {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
h := newHub(ctx, chainDict(), time.Second, time.Second, time.Minute, 10)
return newRoom(h, "AAAAAA", time.Second, time.Second, time.Minute, roomModePvP)
}
// awaitFrame blocks on a session's outbox until a frame satisfies want, so a
// handler-level test waits on the event itself rather than on a sleep.
func awaitFrame(t *testing.T, s *session, want func(*noituv1.ServerMessage) bool) *noituv1.ServerMessage {
t.Helper()
timeout := time.After(5 * time.Second)
for {
select {
case raw := <-s.out:
var m noituv1.ServerMessage
if err := proto.Unmarshal(raw, &m); err != nil {
t.Fatalf("decode: %v", err)
}
if want(&m) {
return &m
}
case <-timeout:
t.Fatal("the awaited frame never reached the session")
}
}
}
func errorCode(m *noituv1.ServerMessage) string { return m.GetError().GetCode() }
// TestKickedPlayersTokenCannotResumeIntoAReusedSeat: seat ids are reused. A
// player who dropped, was kicked, and comes back inside their grace window
// must be refused, not dropped into whoever holds the id now.
func TestKickedPlayersTokenCannotResumeIntoAReusedSeat(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{GraceFor: 10 * time.Second})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
alice := dial(t, url)
aliceWelcome := alice.hello("Alice")
alice.joinRoom(code)
host.await("room_state")
_ = alice.conn.Close(websocket.StatusAbnormalClosure, "")
host.await("room_state") // Alice is away
host.kickPlayer("p2")
host.await("room_state") // her seat is free
carol := dial(t, url)
carolWelcome := carol.hello("Carol")
carol.joinRoom(code)
host.await("room_state")
carol.say("carol-only secret")
host.await("chat_message")
_ = carol.conn.Close(websocket.StatusAbnormalClosure, "")
host.await("room_state") // Carol is away, on the seat Alice used to hold
stale := resumeAs(t, url, "Alice", aliceWelcome.GetResumeToken())
if got := stale.await("error").GetError().GetCode(); got != "session_not_resumable" {
t.Fatalf("a kicked player's token got %q, want session_not_resumable", got)
}
// The seat still belongs to its real occupant.
back := resumeAs(t, url, "Carol", carolWelcome.GetResumeToken())
history := back.await("chat_history").GetChatHistory().GetMessages()
if len(history) != 1 || !history[0].GetFromMe() {
t.Errorf("the real occupant's resume did not restore their own seat: %+v", history)
}
}
// TestRoomExitAnswersInputsQueuedBehindTheLastOne: the owner leaves an empty
// room while a friend's join is already queued behind it. The room is gone by
// the time the join is reached, and the friend must be told so rather than
// waiting forever.
func TestRoomExitAnswersInputsQueuedBehindTheLastOne(t *testing.T) {
r := handlerRoom(t)
host, friend := offlineSession(t, 64), offlineSession(t, 64)
r.inputs <- createInput{sess: host}
r.inputs <- lobbyInput{sess: host, player: "p1", action: lobbyLeave}
r.inputs <- joinInput{sess: friend}
r.inputs <- resumeInput{player: "p1", sess: friend, prior: host}
r.run()
got := queued(t, friend)
var codes []string
for _, m := range got {
codes = append(codes, errorCode(m))
}
if !slices.Contains(codes, "room_not_found") || !slices.Contains(codes, "session_not_resumable") {
t.Errorf("inputs queued behind the room's last were answered with %v, want room_not_found and session_not_resumable", codes)
}
}
// TestDisconnectNoticeSurvivesAFullInbox: the notice that a socket died is
// what starts its seat's reconnect window. Dropped by a full inbox, the seat
// stays bound to a dead connection and counts as connected forever.
func TestDisconnectNoticeSurvivesAFullInbox(t *testing.T) {
r := handlerRoom(t)
host := offlineSession(t, 4*roomInputCap)
host.room, host.playerID = r, "p1"
r.inputs <- createInput{sess: host}
for len(r.inputs) < cap(r.inputs) {
r.inputs <- lobbyInput{sess: host, player: "p1", action: lobbyReady, ready: true}
}
left := make(chan struct{})
go func() {
host.leaveRoom()
close(left)
}()
go r.run()
select {
case <-left:
case <-time.After(5 * time.Second):
t.Fatal("leaveRoom never returned")
}
// FIFO: this is handled after the disconnect, so a seat that entered its
// grace window refuses it, and one still bound to the dead connection
// delivers it.
r.sendReliably(chatInput{sess: host, player: "p1", text: "still here?"})
got := awaitFrame(t, host, func(m *noituv1.ServerMessage) bool {
return m.GetChatMessage() != nil || errorCode(m) == "not_your_seat"
})
if errorCode(got) != "not_your_seat" {
t.Errorf("the seat is still bound to the disconnected session: got %s", describe(got))
}
}
// TestBusyRoomIsNotReportedAsOver: a resume that finds the room's inbox full
// is being told the room is busy; game_already_over would be a false
// statement about a room that is alive.
func TestBusyRoomIsNotReportedAsOver(t *testing.T) {
r := handlerRoom(t)
prior, next := offlineSession(t, 8), offlineSession(t, 8)
prior.room, prior.playerID = r, "p1"
for len(r.inputs) < cap(r.inputs) {
r.inputs <- lobbyInput{}
}
next.resumeFrom(prior)
if got := queued(t, next); len(got) != 1 || errorCode(got[0]) != "busy" {
t.Errorf("resume into a busy room got %v, want busy", got)
}
r.cancel()
next.resumeFrom(prior)
if got := queued(t, next); len(got) != 1 || errorCode(got[0]) != "game_already_over" {
t.Errorf("resume into a finished room got %v, want game_already_over", got)
}
}
// TestRefusedResignationsAreRateLimited: a resignation costs the room an
// input even when it is refused, so it shares the submission budget instead
// of being free.
func TestRefusedResignationsAreRateLimited(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
host.await("room_state")
const sent = submitBurst + 5
for range sent {
host.resign()
}
throttled := 0
for range sent {
if host.await("error").GetError().GetCode() == "too_fast" {
throttled++
}
}
if throttled == 0 {
t.Error("no refused resignation was rate limited")
}
}
// TestQuickMatchAutoStartDoesNotOutliveItsPairing: the pairing's joiner died
// before it was seated. The room must not start a game for whoever fills the
// second seat afterwards, because nobody readied for it.
func TestQuickMatchAutoStartDoesNotOutliveItsPairing(t *testing.T) {
r := handlerRoom(t)
waiter, partner, friend := offlineSession(t, 64), offlineSession(t, 64), offlineSession(t, 64)
r.handleCreate(createInput{sess: waiter, autoStart: true})
partner.cancel() // gone between the pairing and the seating
r.handleJoin(joinInput{sess: partner})
r.vacate(r.seatOf("p2"))
r.handleJoin(joinInput{sess: friend})
if r.engine != nil {
t.Error("a game started for a joiner nobody had readied with")
}
}
// TestResumeLeavesTheQuickMatchQueue: a connection that resumes into a seat
// while queued must not stay in the queue, or a later pairing pulls it out of
// the game it is playing.
func TestResumeLeavesTheQuickMatchQueue(t *testing.T) {
r := handlerRoom(t)
prior, next := offlineSession(t, 64), offlineSession(t, 64)
r.handleCreate(createInput{sess: prior})
r.holdSeat(r.seatOf("p1"))
r.hub.waiting = []*session{next}
r.handleResume(resumeInput{player: "p1", sess: next, prior: prior})
if len(r.hub.waiting) != 0 {
t.Error("a resumed connection is still queued for a quick match")
}
}
// TestQuickMatchIsRefusedWhileDraining: a player queued now would wait for a
// stranger until the shutdown.
func TestQuickMatchIsRefusedWhileDraining(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người chơi")
api.StartDraining()
c.quickMatch()
if got := c.await("error").GetError().GetCode(); got != "server_restarting" {
t.Errorf("quick match while draining got %q, want server_restarting", got)
}
if n := len(api.hub.waiting); n != 0 {
t.Errorf("%d sessions queued while draining", n)
}
}
// TestEmptyMessageIsAnswered: a frame with no payload, or one from a newer
// client, is told it was not understood instead of being dropped.
func TestEmptyMessageIsAnswered(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người chơi")
c.send(&noituv1.ClientMessage{})
if got := c.await("error").GetError().GetCode(); got != "unknown_message" {
t.Errorf("an empty message got %q, want unknown_message", got)
}
}
// TestRefusedActionsDoNotKeepALobbyAlive: the idle window restarts when the
// room changes, not when somebody pokes it. A refused action every 100ms must
// not hold a 300ms lobby open.
func TestRefusedActionsDoNotKeepALobbyAlive(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{IdleFor: 300 * time.Millisecond})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
host.await("room_state")
// The owner has no readiness, so each of these is refused and changes
// nothing.
const pokes = 9
for range pokes {
host.setReady(true)
time.Sleep(100 * time.Millisecond)
}
refusedBeforeClose := 0
for {
m := host.recv()
switch errorCode(m) {
case "owner_needs_no_ready":
refusedBeforeClose++
continue
case "room_idle_closed":
default:
continue
}
break
}
if refusedBeforeClose == pokes {
t.Error("the lobby outlived the whole run of refused actions")
}
}
// TestReadyToggleKeepsALobbyAlive: a readiness change is the room changing,
// so it restarts the idle window.
func TestReadyToggleKeepsALobbyAlive(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{IdleFor: 400 * time.Millisecond})
host, guest, _ := pvpLobby(t, url)
start := time.Now()
for i := range 4 {
guest.setReady(i%2 == 0)
host.await("room_state")
time.Sleep(150 * time.Millisecond)
}
if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" {
t.Fatalf("got %q, want room_idle_closed", got)
}
if elapsed := time.Since(start); elapsed < 700*time.Millisecond {
t.Errorf("the lobby closed after %v, though readiness changed for ~600ms", elapsed)
}
}
// TestSilentSocketIsClosed: a socket that never says Hello holds a slot of the
// global connection cap for nothing, so it is closed after the handshake
// deadline. A greeted connection is left alone.
func TestSilentSocketIsClosed(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{}, func(s *Server) {
s.hub.helloTimeout = 200 * time.Millisecond
})
greeted := dial(t, url)
greeted.hello("Người chơi")
silent := dial(t, url)
opened := time.Now()
for {
if _, err := silent.read(); err != nil {
// read gives up after five seconds on its own, which would look
// the same as a close; the server's deadline is a fraction of that.
if time.Since(opened) > 3*time.Second {
t.Fatalf("the silent socket was never closed by the server: %v", err)
}
break
}
}
// The silent socket outlived its deadline, and the greeted one started
// earlier, so its deadline has passed too.
greeted.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{ClientTimeMs: 7}}})
if got := greeted.await("pong").GetPong().GetClientTimeMs(); got != 7 {
t.Errorf("pong echoed %d, want 7", got)
}
}
// TestRoomBudgetIsPerAddress: reconnecting mints a fresh per-connection
// budget, so the address is what has to hold the line.
func TestRoomBudgetIsPerAddress(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{}, func(s *Server) {
s.hub.roomLimiter = newKeyedLimiter(0.001, roomBurst, time.Hour)
})
first := dial(t, url)
first.hello("Một")
for range roomBurst {
first.createRoom()
first.await("room_state")
}
// A new connection has a full budget of its own, but the same address.
second := dial(t, url)
second.hello("Hai")
second.createRoom()
if got := second.await("error").GetError().GetCode(); got != "too_many_rooms" {
t.Errorf("a reconnect got %q, want too_many_rooms", got)
}
}
// TestLimiterKeyFoldsAddressesToWhatOneClientOwns: an IPv4-mapped address is
// its IPv4 host, and an IPv6 client owns a whole /64.
func TestLimiterKeyFoldsAddressesToWhatOneClientOwns(t *testing.T) {
cases := []struct{ in, want string }{
{"203.0.113.9", "203.0.113.9"},
{"::ffff:203.0.113.9", "203.0.113.9"},
{"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "2001:db8:1:2::/64"},
{"2001:db8:1:2:1::1", "2001:db8:1:2::/64"},
{"2001:db8:1:3::1", "2001:db8:1:3::/64"},
{"fe80::1%eth0", "fe80::/64"},
{"not-an-address", "not-an-address"},
{"", ""},
}
for _, tc := range cases {
if got := limiterKey(tc.in); got != tc.want {
t.Errorf("limiterKey(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestCorpusLogIsBoundedProcessWide: only a flood is sampled, and the gap is
// counted and reported on the next line through.
func TestCorpusLogIsBoundedProcessWide(t *testing.T) {
type line struct {
msg string
args []any
}
var lines []line
now := time.Now()
c := newCorpusLogger(now)
c.out = func(msg string, args ...any) { lines = append(lines, line{msg, args}) }
before := metrics.corpusLogSuppressed.Value()
const total = corpusLogBurst + 50
for range total {
c.info(now, "word_rejected", "word", "x")
}
if len(lines) != corpusLogBurst {
t.Fatalf("%d lines got through at one instant, want the burst of %d", len(lines), corpusLogBurst)
}
if got := metrics.corpusLogSuppressed.Value() - before; got != 50 {
t.Errorf("suppressed counter rose by %d, want 50", got)
}
c.info(now.Add(time.Second), "word_rejected", "word", "x")
last := lines[len(lines)-1]
if len(last.args) < 2 || last.args[len(last.args)-2] != "suppressed_before" || last.args[len(last.args)-1] != int64(50) {
t.Errorf("the line after the gap did not report it: %v", last.args)
}
}
// TestResponsesCarrySecurityHeaders covers the small text endpoints and an
// unknown path alike: the headers are set before routing.
func TestResponsesCarrySecurityHeaders(t *testing.T) {
api, _ := newTestServer(t, chainDict(), Config{})
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "frame-ancestors 'self'",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
for _, path := range []string{"/healthz", "/version", "/no/such/page"} {
rec := httptest.NewRecorder()
api.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
for name, value := range want {
if got := rec.Header().Get(name); got != value {
t.Errorf("GET %s: %s = %q, want %q", path, name, got, value)
}
}
}
}
// TestResumeIntoTheLobbyReplaysAMissedGameOver: a player who was away when the
// game ended comes back to the lobby it returned to, and is owed the result,
// after the room state.
func TestResumeIntoTheLobbyReplaysAMissedGameOver(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: time.Second, GraceFor: 10 * time.Second})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
guest := dial(t, url)
guestWelcome := guest.hello("Khách")
guest.joinRoom(code)
readyAndStart(t, host, guest)
hostStart := host.await("game_started").GetGameStarted()
guest.await("game_started")
_ = guest.conn.Close(websocket.StatusAbnormalClosure, "")
host.await("room_state") // the guest is away
// The game ends while the guest is gone: the host gives up on their turn,
// or the absent guest runs out the clock on theirs.
if hostStart.GetMyTurn() {
host.resign()
}
host.await("game_over")
back := resumeAs(t, url, "Khách", guestWelcome.GetResumeToken())
var order []string
var over *noituv1.GameOver
for over == nil {
m := back.recv()
order = append(order, payloadCase(m))
over = m.GetGameOver()
if len(order) > 6 {
t.Fatalf("no game_over replayed after the resume: %v", order)
}
}
if order[len(order)-2] != "room_state" {
t.Errorf("frames = %v, want game_over right after room_state", order)
}
// The guest's own version: the host resigned exactly when it was theirs to
// act, which hands the guest the win, and otherwise the guest lost the clock.
if over.GetIWon() != hostStart.GetMyTurn() {
t.Errorf("i_won = %v, want %v", over.GetIWon(), hostStart.GetMyTurn())
}
if len(over.GetStandings()) != 2 || !slices.ContainsFunc(over.GetStandings(), (*noituv1.PlayerScore).GetIsMe) {
t.Errorf("standings are not rendered for the resuming seat: %+v", over.GetStandings())
}
}
// TestResumeReplaysNothingForAGameSeenLive: a player who was connected when
// the game ended already has the result, so a later refresh in the lobby is
// answered with the lobby alone.
func TestResumeReplaysNothingForAGameSeenLive(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
host := dial(t, url)
welcome := host.hello("Chủ phòng")
host.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
guest := dial(t, url)
guest.hello("Khách")
guest.joinRoom(code)
readyAndStart(t, host, guest)
start := host.await("game_started").GetGameStarted()
guest.await("game_started")
resignAndSettle(t, host, guest, start)
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
back := resumeAs(t, url, "Chủ phòng", welcome.GetResumeToken())
back.await("room_state")
back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{ClientTimeMs: 1}}})
if m := back.recv(); payloadCase(m) != "pong" {
t.Errorf("after the lobby state the next frame was %s, want nothing replayed", describe(m))
}
}