Files
noitu/server/internal/wsapi/regression_test.go
T
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
2026-09-08 10:56:14 +07:00

516 lines
18 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.
func startPvP(t *testing.T, url string) (host, guest *testClient, code string) {
t.Helper()
host = dial(t, url)
host.hello("Chủ phòng")
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code = host.await("room_state").GetRoomState().GetRoomCode()
guest = dial(t, url)
guest.hello("Khách")
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
}})
host.await("room_state")
guest.await("room_state")
// The lobby is where a game is agreed now: the guest readies and the owner
// starts it.
guest.setReady(true)
host.await("room_state")
host.startGame()
host.await("game_started")
guest.await("game_started")
return host, guest, 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code := host.await("room_state").GetRoomState().GetRoomCode()
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.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 guest 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})
host, guest, _ := startPvP(t, url)
// a b (opening) -> b c -> c d -> d e, after which the guest has nothing.
host.submit("b c", 1)
guestTurn := awaitMyTurn(t, guest)
guest.submit("c d", guestTurn.GetTurnSeq())
hostTurn := awaitMyTurn(t, host)
host.submit("d e", hostTurn.GetTurnSeq())
// The elimination comes first and carries what the position had left, so
// it has to be read before the result it caused.
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
hostOut := host.await("player_eliminated").GetPlayerEliminated()
if !guestOut.GetIsMe() || hostOut.GetIsMe() {
t.Error("is_me should be true only for the player who went out")
}
hostOver := host.await("game_over").GetGameOver()
guestOver := guest.await("game_over").GetGameOver()
if hostOver.GetIWon() == guestOver.GetIWon() {
t.Fatalf("both players were told the same outcome: host=%v guest=%v",
hostOver.GetIWon(), guestOver.GetIWon())
}
if !hostOver.GetIWon() {
t.Error("the player who left their opponent with no legal move should win")
}
if hostOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.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 := guestOut.GetSuggestions(); len(got) != 0 {
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
}
if got := hostOut.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})
host, guest, _ := startPvP(t, url)
// The opening is "a b", so "b c" is still there to be played.
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
hostOut := host.await("player_eliminated").GetPlayerEliminated()
guestOver := guest.await("game_over").GetGameOver()
if guestOver.GetIWon() {
t.Fatal("the player who resigned was told they won")
}
if got := guestOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
t.Errorf("suggestions = %v, want [b c]", got)
}
if got := hostOut.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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: 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.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
code := host.await("room_state").GetRoomState().GetRoomCode()
guest := dial(t, url)
guest.hello("Khách")
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
JoinRoom: &noituv1.JoinRoom{RoomCode: 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)
}
})
}
}