Files
noitu/server/internal/wsapi/regression_test.go
T
tiennm99 d481a093ec feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.

Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.

A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.

Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.

The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
2026-09-07 16:32:14 +07:00

479 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)
)
// 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},
}})
if got := stranger.await("error").GetError().GetCode(); got != "room_full" {
t.Fatalf("third joiner got %q, want room_full", 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())
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 loser is
// told so. The winner is never sent one.
if got := guestOver.GetSuggestions(); len(got) != 0 {
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
}
if got := hostOver.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{}}})
guestOver := guest.await("game_over").GetGameOver()
hostOver := host.await("game_over").GetGameOver()
if guestOver.GetIWon() {
t.Fatal("the player who resigned was told they won")
}
if got := guestOver.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
t.Errorf("suggestions = %v, want [b c]", got)
}
if got := hostOver.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 := host.await("room_state").GetRoomState().GetOpponentName()
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 !state.GetOpponentPresent() {
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)
}
})
}
}