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

237 lines
8.5 KiB
Go

package wsapi
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
)
// TestPlayedWordNamesItsPlayerOnTheWire is the producer-side check: the chain
// byline in a room of three or four is drawn from PlayedWord.player_id, and a
// fixture that hand-writes the field proves nothing about the room.
func TestPlayedWordNamesItsPlayerOnTheWire(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
lead, waits, start := pvpGame(t, url)
lead.submit("b c", start.GetTurnSeq())
played := waits.await("turn_update").GetTurnUpdate().GetPlayed()
if played.GetPlayerId() == "" {
t.Fatal("player_id is empty on the wire")
}
if played.GetPlayerId() != start.GetTurnPlayerId() {
t.Errorf("player_id = %q, want the leader %q", played.GetPlayerId(), start.GetTurnPlayerId())
}
}
// TestTypedWordIsSanitizedBeforeItIsEchoed guards the trust boundary the typed
// text crosses: every seat is shown it, so format characters must be gone
// before the engine stores it.
func TestTypedWordIsSanitizedBeforeItIsEchoed(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
lead, waits, start := pvpGame(t, url)
// A zero-width joiner and a bidi override inside an otherwise legal word.
lead.submit("b\u200d \u202ec", start.GetTurnSeq())
played := waits.await("turn_update").GetTurnUpdate().GetPlayed()
if played.GetWord() != "b c" {
t.Fatalf("word = %q, want the move accepted as %q", played.GetWord(), "b c")
}
if played.GetTyped() != "b c" {
t.Errorf("typed = %q still carries non-printing characters", played.GetTyped())
}
}
// TestRoomCapRefusesTheNextRoom bounds live rooms across the process, not per
// connection: a fleet of connections each under its own limiter must still
// hit a ceiling.
func TestRoomCapRefusesTheNextRoom(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{MaxRooms: 2})
for range 2 {
c := dial(t, url)
c.hello("Chủ phòng")
c.createRoom()
c.await("room_state")
}
third := dial(t, url)
third.hello("Người thứ ba")
third.createRoom()
if got := third.await("error").GetError().GetCode(); got != "server_full" {
t.Errorf("error code = %q, want server_full", got)
}
}
// TestConnectionCapRefusesBeforeUpgrade: the refusal is an HTTP status a
// client can read, and it costs the server no socket.
func TestConnectionCapRefusesBeforeUpgrade(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{MaxConnections: 1})
first := dial(t, url)
first.hello("Một")
_, resp, err := websocket.Dial(t.Context(), url+"/ws", nil)
if err == nil {
t.Fatal("second connection was accepted past the cap")
}
if resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("want 503 before the upgrade, got %v (err %v)", resp, err)
}
}
// TestPerIPConnectionCapRefusesBeforeUpgrade: off by default, on it refuses
// the same way the global cap does — before the upgrade, so the client reads
// an HTTP status rather than losing a socket it was never granted.
func TestPerIPConnectionCapRefusesBeforeUpgrade(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{MaxConnectionsPerIP: 1})
first := dial(t, url)
first.hello("Một")
_, resp, err := websocket.Dial(t.Context(), url+"/ws", nil)
if err == nil {
t.Fatal("a second connection from the same address was accepted past the per-IP cap")
}
if resp == nil || resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("want 503 before the upgrade, got %v (err %v)", resp, err)
}
}
// TestPerIPConnectionCapIsOffByDefault: a zero MaxConnectionsPerIP must not
// refuse anything — most players share an address behind one NAT egress, and
// the cap defaults off for exactly that reason.
func TestPerIPConnectionCapIsOffByDefault(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
for range 3 {
c := dial(t, url)
c.hello("Người chơi")
}
}
// TestPerIPConnectionCapReleasesOnDisconnect: the slot a closed connection
// held must be free for the next one, or the cap would starve an address
// permanently after its first burst of reconnects.
func TestPerIPConnectionCapReleasesOnDisconnect(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{MaxConnectionsPerIP: 1})
first := dial(t, url)
first.hello("Một")
_ = first.conn.Close(websocket.StatusNormalClosure, "")
settle()
second := dial(t, url)
second.hello("Hai")
}
// TestFrameFloodClosesTheConnection: a message that matches no dispatch arm
// used to be free at line rate. Now every frame is metered before it is
// decoded, and a flood is closed rather than throttled.
func TestFrameFloodClosesTheConnection(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người thử")
empty, _ := proto.Marshal(&noituv1.ClientMessage{})
for range frameBurst + 20 {
if err := c.conn.Write(c.ctx, websocket.MessageBinary, empty); err != nil {
return // closed on us mid-flood, which is the point
}
}
// Each frame within the burst is answered as unknown, so the close arrives
// after those replies rather than in place of them.
for range 2 * (frameBurst + 20) {
_, _, err := c.conn.Read(c.ctx)
if err != nil {
return
}
}
t.Error("connection survived a frame flood")
}
// TestClientIPTrustsOnlyConfiguredProxies covers the three shapes the limiter
// key can take: no proxy configured, a trusted proxy carrying a forwarded
// chain, and a forged header from a peer that is not a proxy.
func TestClientIPTrustsOnlyConfiguredProxies(t *testing.T) {
req := func(remote, xff string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/ws", nil)
r.RemoteAddr = remote
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
}
return r
}
plain := &Server{}
if got := plain.clientIP(req("203.0.113.9:4000", "10.0.0.1")); got != "203.0.113.9" {
t.Errorf("no proxies configured: got %q, want the peer", got)
}
s := &Server{proxies: parsePrefixes([]string{"127.0.0.1", "10.0.0.0/8", "not-an-address"})}
cases := []struct{ remote, xff, want string }{
// The proxy appended the client; the client forged nothing.
{"127.0.0.1:5000", "198.51.100.7", "198.51.100.7"},
// Two trusted hops behind the peer, then the client.
{"127.0.0.1:5000", "198.51.100.7, 10.1.2.3", "198.51.100.7"},
// The client forged a header; the proxy appended the real address
// after it, and the rightmost untrusted entry wins.
{"10.9.9.9:5000", "1.2.3.4, 198.51.100.7", "198.51.100.7"},
// A peer that is not a proxy is taken at its socket address, header
// or not.
{"203.0.113.9:4000", "198.51.100.7", "203.0.113.9"},
// A garbage hop falls back to the proxy itself rather than keying a
// bucket on whatever was typed.
{"127.0.0.1:5000", "not an ip", "127.0.0.1"},
// An IPv4-mapped peer still matches its IPv4 prefix.
{"[::ffff:127.0.0.1]:5000", "198.51.100.7", "198.51.100.7"},
// A client's key is the address it owns, whichever way it arrived.
{"[::ffff:203.0.113.9]:4000", "", "203.0.113.9"},
{"[2001:db8:1:2:aaaa::1]:4000", "", "2001:db8:1:2::/64"},
{"127.0.0.1:5000", "2001:db8:1:2:bbbb::7", "2001:db8:1:2::/64"},
}
for _, tc := range cases {
if got := s.clientIP(req(tc.remote, tc.xff)); got != tc.want {
t.Errorf("remote=%s xff=%q: got %q, want %q", tc.remote, tc.xff, got, tc.want)
}
}
}
// TestIdleRoomReleasesItsSeats: a room that closes on its idle clock must let
// go of the connections still sitting in it, or they are stuck pointing at a
// goroutine that has exited and can never be seated cleanly again.
func TestIdleRoomReleasesItsSeats(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{IdleFor: 200 * time.Millisecond})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
host.await("room_state")
if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" {
t.Fatalf("error code = %q, want room_idle_closed", got)
}
settle()
if n := api.hub.roomCount(); n != 0 {
t.Fatalf("%d rooms still registered after the idle close", n)
}
// Released, not merely tolerated: it must not still point at the dead room,
// which would answer this as busy instead of as no room at all.
host.say("x")
if got := host.await("error").GetError().GetCode(); got != "not_in_a_room" {
t.Errorf("chat after the idle close returned %q, want not_in_a_room", got)
}
// The connection is free again: a second room opens and seats it.
host.createRoom()
if host.await("room_state").GetRoomState().GetRoomCode() == "" {
t.Error("could not be seated in a new room after the idle close")
}
}