Files
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00

491 lines
17 KiB
Go

package wsapi
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/coder/websocket"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
)
// Named so a reader can see what the sanitizer tests are sending: all three
// are invisible in source, which is exactly why they matter as input.
const (
nul = rune(0)
zeroWidthSpace = rune(0x200B)
bidiOverride = rune(0x202E)
// A combining mark: printable, uncomposable, and invisible to every
// filter the sanitizer had before chat needed a cap on stacking.
stackingMark = rune(0x0350)
)
// TestNicknamesCannotStackCombiningMarks: the cap chat needed applies here
// too, and it has to, because a name is rendered in a stranger's browser on
// the same screen. Vietnamese is unaffected — NFC composes every vowel and
// tone into one rune, so a real name carries no combining marks at all.
func TestNicknamesCannotStackCombiningMarks(t *testing.T) {
name := sanitizeNickname("Minh" + strings.Repeat(string(stackingMark), 50))
if marks := strings.Count(name, string(stackingMark)); marks > maxNicknameMarks {
t.Errorf("a nickname kept %d combining marks, want at most %d", marks, maxNicknameMarks)
}
if !strings.HasPrefix(name, "Minh") {
t.Errorf("the cap ate the name itself: %q", name)
}
if got := sanitizeNickname("Nguyễn Văn Tú"); got != "Nguyễn Văn Tú" {
t.Errorf("a Vietnamese name was changed by the mark cap: %q", got)
}
}
// Regressions for defects found in review. Each one failed before its fix, so
// each is a guard against the same mistake returning rather than a restatement
// of behaviour already covered elsewhere.
// startPvP runs two clients up to the point where the game is live, and hands
// them back in turn order: lead moves first, waits answers.
func startPvP(t *testing.T, url string) (lead, waits *testClient, code string) {
t.Helper()
host, guest, code := pvpLobby(t, url)
agreeAndStart(host, guest)
lead, waits, _ = awaitLead(t, host, guest)
return lead, waits, code
}
// silentFor asserts the client receives no message of the given kind within a
// short window. Used where the correct behaviour is that nothing happens.
func silentFor(t *testing.T, c *testClient, unwanted string, d time.Duration) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel()
for {
_, raw, err := c.conn.Read(ctx)
if err != nil {
return // nothing arrived, which is the pass
}
var m noituv1.ServerMessage
if err := proto.Unmarshal(raw, &m); err != nil {
return
}
if payloadCase(&m) == unwanted {
t.Fatalf("received an unexpected %q", unwanted)
}
}
}
// TestStrangerCannotResignForASeatedPlayer is the authorization case. A room
// code is pasted into group chats by design, so holding one must not be enough
// to act as a player who is already seated.
func TestStrangerCannotResignForASeatedPlayer(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
_, guest, code := startPvP(t, url)
stranger := dial(t, url)
stranger.hello("Kẻ lạ")
stranger.joinRoom(code)
// Not room_full: a four-seat room with two people in it has seats going
// spare. Arriving in the middle of a game is what is refused, and a
// stranger holding the code is refused it like anybody else.
if got := stranger.await("error").GetError().GetCode(); got != "game_in_progress" {
t.Fatalf("third joiner got %q, want game_in_progress", got)
}
stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
silentFor(t, guest, "game_over", 400*time.Millisecond)
}
// TestStrangerCannotSubmitForASeatedPlayer is the same hole via SubmitWord:
// turn_seq is a small integer, so its being unguessable is not a defence.
func TestStrangerCannotSubmitForASeatedPlayer(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
host, _, code := startPvP(t, url)
stranger := dial(t, url)
stranger.hello("Kẻ lạ")
stranger.joinRoom(code)
stranger.await("error")
stranger.submit("b c", 1)
silentFor(t, host, "turn_update", 400*time.Millisecond)
}
// TestCannotJoinYourOwnRoom stops a creator occupying both seats, which would
// leave one connection playing itself.
func TestCannotJoinYourOwnRoom(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
host.joinRoom(code)
if got := host.await("error").GetError().GetCode(); got != "cannot_join_own_room" {
t.Errorf("self-join returned %q", got)
}
}
// TestShutdownNotifiesPlayersMidGame is acceptance criterion (i). It races
// Shutdown against in-flight reads, which is where cancelling the read context
// used to destroy the socket before the notice could leave.
//
// Several connections rather than one: whether any single socket loses that
// race is timing, so one client would make this an intermittent guard against
// a defect that is entirely deterministic in its consequences.
func TestShutdownNotifiesPlayersMidGame(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
const players = 8
clients := make([]*testClient, 0, players)
for i := range players {
c := dial(t, url)
c.hello("Người chơi")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
}})
c.await("game_started")
clients = append(clients, c)
_ = i
}
api.Shutdown()
for i, c := range clients {
if got := c.await("error").GetError().GetCode(); got != "server_restarting" {
t.Errorf("client %d shutdown notice = %q, want server_restarting", i, got)
}
}
}
// TestAbandonedRoomIsEvicted covers the leak: a room whose game never started
// has no turn timer and no opponent, so nothing else could ever end it.
func TestAbandonedRoomIsEvicted(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
host.await("room_state")
_ = host.conn.Close(websocket.StatusGoingAway, "")
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if api.hub.roomCount() == 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Errorf("%d rooms still live after the only player left before the game began", api.hub.roomCount())
}
// TestRoomCreationIsRateLimited stops one connection minting goroutines,
// engines and registry entries without bound.
func TestRoomCreationIsRateLimited(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người thử")
for range roomBurst + 3 {
c.createRoom()
}
for range 40 {
m := c.recv()
if payloadCase(m) == "error" && m.GetError().GetCode() == "too_many_rooms" {
return
}
}
t.Error("one connection created rooms past the burst without being limited")
}
// TestPvPGameRunsToAWinner plays a PvP game to its natural end, rather than
// stopping after the first move as the alternation test does.
//
// The dead end at the end of the chain does not decide the game by itself:
// the second player is left holding a turn nobody could answer, and it is the
// clock that ends it. The turn limit is short because this test spends one.
func TestPvPGameRunsToAWinner(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: time.Second})
lead, waits, _ := startPvP(t, url)
// a b (opening) -> b c -> c d -> d e, after which waits has nothing.
lead.submit("b c", 1)
waitsTurn := awaitMyTurn(t, waits)
waits.submit("c d", waitsTurn.GetTurnSeq())
leadTurn := awaitMyTurn(t, lead)
lead.submit("d e", leadTurn.GetTurnSeq())
// The elimination comes first and carries what the position had left, so
// it has to be read before the result it caused.
loserOut := waits.await("player_eliminated").GetPlayerEliminated()
winnerOut := lead.await("player_eliminated").GetPlayerEliminated()
if !loserOut.GetIsMe() || winnerOut.GetIsMe() {
t.Error("is_me should be true only for the player who went out")
}
winnerOver := lead.await("game_over").GetGameOver()
loserOver := waits.await("game_over").GetGameOver()
if winnerOver.GetIWon() == loserOver.GetIWon() {
t.Fatalf("both players were told the same outcome: lead=%v waits=%v",
winnerOver.GetIWon(), loserOver.GetIWon())
}
if !winnerOver.GetIWon() {
t.Error("the player who left their opponent with no legal move should win")
}
if winnerOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
t.Errorf("reason = %v, want NO_LEGAL_MOVE", winnerOver.GetReason())
}
// Nothing could have been played, and the empty list is how the player who
// was stuck is told so. It rides on the elimination rather than the
// result: it describes the position they were looking at, which by the end
// of a longer game is nobody else's position.
if got := loserOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
}
if got := winnerOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the winner was sent suggestions %v, want none", got)
}
}
// TestLosingPlayerIsToldWhatCouldHaveBeenPlayed covers the other half of the
// same message: a player who loses a position that still had words in it is
// shown some of them.
func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
lead, waits, _ := startPvP(t, url)
// The player to act gives up, which is the only seat that may: the opening
// is "a b", so "b c" was still there for them to play.
lead.resign()
loserOut := lead.await("player_eliminated").GetPlayerEliminated()
winnerOut := waits.await("player_eliminated").GetPlayerEliminated()
loserOver := lead.await("game_over").GetGameOver()
if loserOver.GetIWon() {
t.Fatal("the player who resigned was told they won")
}
if got := loserOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
t.Errorf("suggestions = %v, want [b c]", got)
}
if got := winnerOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the winner was sent suggestions %v, want none", got)
}
}
// awaitMyTurn reads turn updates until this client is the one on turn. Both
// players see every move, so the first update after a submission is the
// player's own echo.
func awaitMyTurn(t *testing.T, c *testClient) *noituv1.TurnUpdate {
t.Helper()
for range 10 {
if u := c.await("turn_update").GetTurnUpdate(); u.GetMyTurn() {
return u
}
}
t.Fatal("never became the player on turn")
return nil
}
// TestOpponentNeverSeesAnUnsanitizedNickname is the boundary that matters:
// sanitization is only worth anything if the hostile string cannot reach the
// other player's screen.
func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
host := dial(t, url)
host.hello("Chủ phòng")
host.createRoom()
code := host.await("room_state").GetRoomState().GetRoomCode()
hostile := " Kẻ" + string(nul) + " xấu" + string(zeroWidthSpace) + string(bidiOverride) +
" " + strings.Repeat("z", 40)
guest := dial(t, url)
guest.hello(hostile)
guest.joinRoom(code)
shown := otherSlot(host.await("room_state").GetRoomState()).GetName()
for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} {
if strings.ContainsRune(shown, r) {
t.Errorf("opponent name %q still carries the invisible rune %U", shown, r)
}
}
if n := len([]rune(shown)); n > maxNicknameRunes {
t.Errorf("opponent name is %d runes, over the %d cap", n, maxNicknameRunes)
}
}
// TestRepeatedHelloIsRejected keeps the handshake a one-shot transition: a
// second Hello would rewrite the nickname of a player already in a game.
func TestRepeatedHelloIsRejected(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người thử")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: ProtocolVersion,
Nickname: "Người khác",
}}})
if got := c.await("error").GetError().GetCode(); got != "already_greeted" {
t.Errorf("second hello returned %q, want already_greeted", got)
}
}
// TestStaleRejectionCarriesTheServerSequence lets a client resynchronise from
// the refusal instead of waiting for the next turn update to discover where
// the game actually is.
func TestStaleRejectionCarriesTheServerSequence(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
c := dial(t, url)
c.hello("Người thử")
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
}})
started := c.await("game_started").GetGameStarted()
c.submit("b c", started.GetTurnSeq()+99)
got := c.await("move_rejected").GetMoveRejected()
if got.GetTurnSeq() != started.GetTurnSeq() {
t.Errorf("rejection carried seq %d, want the server's %d", got.GetTurnSeq(), started.GetTurnSeq())
}
}
// TestResumeAfterGameEndedLandsInTheLobby. GraceFor is longer than TurnLimit,
// so a player who drops on their own turn loses before the grace window
// closes. Their reconnect must land somewhere real - the lobby the room went
// back to - rather than holding a Welcome and silence.
func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{
TurnLimit: 200 * time.Millisecond,
GraceFor: 5 * time.Second,
})
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)
host.await("game_started")
guest.await("game_started")
// The host is on turn; drop and let the turn clock run out.
_ = host.conn.Close(websocket.StatusGoingAway, "")
guest.await("game_over")
back := dial(t, url)
back.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: ProtocolVersion,
Nickname: "Chủ phòng",
ResumeToken: welcome.GetResumeToken(),
}}})
back.await("welcome")
state := back.await("room_state").GetRoomState()
if state.GetRoomCode() != code {
t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code)
}
if otherSlot(state) == nil {
t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state)
}
}
func TestUnderRootIsAPathBoundaryNotAPrefix(t *testing.T) {
root := filepath.Clean("/srv/web")
if underRoot(root, filepath.Clean("/srv/webhooks/secret")) {
t.Error("a sibling directory sharing a name prefix must not count as inside the root")
}
if !underRoot(root, filepath.Clean("/srv/web/index.html")) {
t.Error("a real child was rejected")
}
if !underRoot(root, root) {
t.Error("the root itself should be allowed")
}
}
// TestRoomCodesAreUnbiased checks the rejection sampling. A modulo over 256
// would make the first eight letters of the alphabet ~12% more likely, which
// this tolerance catches and ordinary randomness does not trip.
func TestRoomCodesAreUnbiased(t *testing.T) {
const draws = 10000
counts := map[rune]int{}
for range draws {
for _, r := range randomCode() {
counts[r]++
}
}
expected := float64(draws*roomCodeLen) / float64(len(roomCodeAlphabet))
for _, r := range roomCodeAlphabet {
got := float64(counts[r])
if got < expected*0.9 || got > expected*1.1 {
t.Errorf("letter %q drawn %.0f times, expected ~%.0f", r, got, expected)
}
}
}
// TestStaticCacheHeaders pins the two cache policies the single-page bundle
// depends on. The failure they prevent is silent and total: an index.html
// cached across a deploy names hashed assets that no longer exist, so the app
// loads into a blank page with nothing in the log.
func TestStaticCacheHeaders(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "_app", "immutable", "chunks")
if err := os.MkdirAll(assets, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assets, "abc123.js"), []byte("export{}"), 0o644); err != nil {
t.Fatal(err)
}
srv := NewServer(context.Background(), newTestDict("ngữ pháp", "pháp luật"), Config{WebDir: dir})
defer srv.Shutdown()
cases := []struct {
name string
path string
want string
}{
{"hashed asset", "/_app/immutable/chunks/abc123.js", "public, max-age=31536000, immutable"},
{"the shell", "/", "no-cache"},
{"a deep link falling back to the shell", "/play?difficulty=2", "no-cache"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != tc.want {
t.Errorf("Cache-Control = %q, want %q", got, tc.want)
}
})
}
}