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(""), 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) } }) } }