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

283 lines
9.5 KiB
Go

package wsapi
import (
"strings"
"testing"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/bot"
"github.com/tiennm99dev/noitu/server/internal/game"
"google.golang.org/protobuf/reflect/protoreflect"
)
// engineRejectReasons enumerates the engine's constants from its own count
// sentinel.
//
// The sentinel is what makes this honest. Walking until String returns
// "unknown" would be circular — a reason added without a String case would
// shorten the range under test and every assertion below would still pass on
// a value that has no mapping.
func engineRejectReasons() []game.RejectReason {
out := make([]game.RejectReason, 0, game.NumRejectReasons)
for i := game.RejectReason(0); i < game.NumRejectReasons; i++ {
out = append(out, i)
}
return out
}
func engineEndReasons() []game.EndReason {
out := make([]game.EndReason, 0, game.NumEndReasons)
for i := game.EndReason(0); i < game.NumEndReasons; i++ {
out = append(out, i)
}
return out
}
// TestEveryEngineReasonIsNamed catches the other half of the same problem: a
// constant added inside the block but not to String. Such a value maps and
// logs as "unknown", which is unreadable in exactly the situation the log
// exists for.
func TestEveryEngineReasonIsNamed(t *testing.T) {
for _, r := range engineRejectReasons() {
if r.String() == "unknown" {
t.Errorf("game.RejectReason(%d) has no String case", int(r))
}
}
for _, r := range engineEndReasons() {
if r.String() == "unknown" {
t.Errorf("game.EndReason(%d) has no String case", int(r))
}
}
}
// TestRejectReasonMappingIsExhaustive fails if the engine gains a rejection
// with no wire counterpart. Only ReasonNone is allowed to land on UNSPECIFIED:
// it means "accepted", so it never travels inside a MoveRejected.
func TestRejectReasonMappingIsExhaustive(t *testing.T) {
reasons := engineRejectReasons()
if len(reasons) < 2 {
t.Fatalf("enumerated %d engine reject reasons, expected the full set", len(reasons))
}
seen := make(map[noituv1.RejectReason]game.RejectReason, len(reasons))
for _, r := range reasons {
got := RejectReason(r)
if r == game.ReasonNone {
if got != noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
t.Errorf("ReasonNone should map to UNSPECIFIED, got %v", got)
}
continue
}
if got == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
t.Errorf("game.RejectReason(%d) %q has no wire mapping", int(r), r)
continue
}
if prev, dup := seen[got]; dup {
t.Errorf("%v is produced by both %q and %q; a wire value must identify one reason", got, prev, r)
}
seen[got] = r
}
// Every wire value must also be reachable. An unreachable one is a contract
// the server can never honour, which a client would be right to handle.
for _, v := range enumValues(noituv1.RejectReason_REJECT_REASON_UNSPECIFIED.Descriptor()) {
w := noituv1.RejectReason(v)
if w == noituv1.RejectReason_REJECT_REASON_UNSPECIFIED {
continue
}
if _, ok := seen[w]; !ok {
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
}
}
}
// TestEndReasonMappingIsExhaustive mirrors the reject-reason walk.
// GAME_END_REASON_OPPONENT_LEFT is the one deliberate gap: a disconnect is a
// transport event the engine has no concept of, so the room emits it directly.
func TestEndReasonMappingIsExhaustive(t *testing.T) {
seen := make(map[noituv1.GameEndReason]game.EndReason)
for _, r := range engineEndReasons() {
got := EndReason(r)
if r == game.EndNone {
if got != noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
t.Errorf("EndNone should map to UNSPECIFIED, got %v", got)
}
continue
}
if got == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED {
t.Errorf("game.EndReason(%d) %q has no wire mapping", int(r), r)
continue
}
if prev, dup := seen[got]; dup {
t.Errorf("%v is produced by both %q and %q", got, prev, r)
}
seen[got] = r
}
transportOnly := map[noituv1.GameEndReason]bool{
noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT: true,
}
for _, v := range enumValues(noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED.Descriptor()) {
w := noituv1.GameEndReason(v)
if w == noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED || transportOnly[w] {
continue
}
if _, ok := seen[w]; !ok {
t.Errorf("wire value %v is unreachable: no engine reason maps to it", w)
}
}
}
func enginePointKinds() []game.PointKind {
out := make([]game.PointKind, 0, game.NumPointKinds)
for i := game.PointKind(0); i < game.NumPointKinds; i++ {
out = append(out, i)
}
return out
}
// TestPointKindMappingIsExhaustive mirrors the reject-reason walk. Only
// PointKindNone is allowed to land on UNSPECIFIED: a PointPart exists only for
// a term that contributed, so no real move ever carries that value.
func TestPointKindMappingIsExhaustive(t *testing.T) {
kinds := enginePointKinds()
if len(kinds) < 2 {
t.Fatalf("enumerated %d engine point kinds, expected the full set", len(kinds))
}
seen := make(map[noituv1.PointKind]game.PointKind, len(kinds))
for _, k := range kinds {
got := PointKind(k)
if k == game.PointKindNone {
if got != noituv1.PointKind_POINT_KIND_UNSPECIFIED {
t.Errorf("PointKindNone should map to UNSPECIFIED, got %v", got)
}
continue
}
if got == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
t.Errorf("game.PointKind(%d) %q has no wire mapping", int(k), k)
continue
}
if want := "POINT_KIND_" + strings.ToUpper(k.String()); got.String() != want {
t.Errorf("game.PointKind %q maps to %v, want %s", k, got, want)
}
if prev, dup := seen[got]; dup {
t.Errorf("%v is produced by both %q and %q", got, prev, k)
}
seen[got] = k
}
for _, v := range enumValues(noituv1.PointKind_POINT_KIND_UNSPECIFIED.Descriptor()) {
w := noituv1.PointKind(v)
if w == noituv1.PointKind_POINT_KIND_UNSPECIFIED {
continue
}
if _, ok := seen[w]; !ok {
t.Errorf("wire value %v is unreachable: no engine point kind maps to it", w)
}
}
}
// TestPlayedWordCarriesItsScoreBreakdown guards the invariant the client
// relies on: the parts it draws beside the total must sum to it exactly.
func TestPlayedWordCarriesItsScoreBreakdown(t *testing.T) {
m := game.Move{
Player: "p1",
Word: "bình yên",
Points: 25,
Parts: []game.PointPart{
{Kind: game.PointKindBase, Value: 10},
{Kind: game.PointKindSpeed, Value: 15},
},
}
rendered := PlayedWord(m, true, nil)
if len(rendered.GetParts()) != 2 {
t.Fatalf("rendered %d parts, want 2", len(rendered.GetParts()))
}
sum := uint32(0)
for _, p := range rendered.GetParts() {
sum += p.GetValue()
}
if sum != rendered.GetPoints() {
t.Errorf("parts sum to %d, want points %d", sum, rendered.GetPoints())
}
if rendered.GetParts()[0].GetKind() != noituv1.PointKind_POINT_KIND_BASE {
t.Errorf("first part kind = %v, want BASE", rendered.GetParts()[0].GetKind())
}
if rendered.GetParts()[1].GetKind() != noituv1.PointKind_POINT_KIND_SPEED {
t.Errorf("second part kind = %v, want SPEED", rendered.GetParts()[1].GetKind())
}
}
// TestDifficultyMapping checks each level and, more importantly, that an
// unknown wire difficulty is refused rather than defaulted. Difficulty arrives
// from the client, so silently treating garbage as Easy would let a malformed
// frame choose the opponent.
func TestDifficultyMapping(t *testing.T) {
want := map[noituv1.Difficulty]bot.Difficulty{
noituv1.Difficulty_DIFFICULTY_EASY: bot.Easy,
noituv1.Difficulty_DIFFICULTY_MEDIUM: bot.Medium,
noituv1.Difficulty_DIFFICULTY_HARD: bot.Hard,
}
for wire, expect := range want {
got, ok := Difficulty(wire)
if !ok || got != expect {
t.Errorf("Difficulty(%v) = (%v, %v), want (%v, true)", wire, got, ok, expect)
}
}
// Every declared level must be reachable, so adding one to the schema
// without teaching the server about it fails here rather than at runtime.
for _, v := range enumValues(noituv1.Difficulty_DIFFICULTY_UNSPECIFIED.Descriptor()) {
d := noituv1.Difficulty(v)
if d == noituv1.Difficulty_DIFFICULTY_UNSPECIFIED {
continue
}
if _, ok := want[d]; !ok {
t.Errorf("wire difficulty %v has no bot strategy", d)
}
}
for _, bad := range []noituv1.Difficulty{
noituv1.Difficulty_DIFFICULTY_UNSPECIFIED,
noituv1.Difficulty(99),
} {
if _, ok := Difficulty(bad); ok {
t.Errorf("Difficulty(%v) accepted an unknown wire value", bad)
}
}
}
// TestPlayedWordKeepsTypedInput guards the correction-visibility rule: the UI
// has to be able to show that canonicalization changed the player's text, so
// the raw input must survive onto the wire.
func TestPlayedWordKeepsTypedInput(t *testing.T) {
m := game.Move{Player: "p2", Word: "hòa bình", Typed: "hoà bình", Syllables: 2, Points: 2}
mine := PlayedWord(m, true, nil)
if mine.GetWord() != "hòa bình" || mine.GetTyped() != "hoà bình" {
t.Errorf("canonical/typed pair lost: word=%q typed=%q", mine.GetWord(), mine.GetTyped())
}
// The byline in a room of three or four is drawn from this field alone;
// by_me only says whether it was the recipient.
if mine.GetPlayerId() != "p2" {
t.Errorf("player_id lost: got %q", mine.GetPlayerId())
}
if !mine.GetByMe() || mine.GetSyllables() != 2 || mine.GetPoints() != 2 {
t.Errorf("unexpected rendering: %+v", mine)
}
if theirs := PlayedWord(m, false, nil); theirs.GetByMe() {
t.Error("by_me must be false for the opponent's copy of the same move")
}
}
func enumValues(d protoreflect.EnumDescriptor) []protoreflect.EnumNumber {
vals := d.Values()
out := make([]protoreflect.EnumNumber, 0, vals.Len())
for i := 0; i < vals.Len(); i++ {
out = append(out, vals.Get(i).Number())
}
return out
}