From 35ddd0ad2215760bb1d3dc7724480f179ea33fa6 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 16 Sep 2026 21:05:39 -0400 Subject: fix: login account race, casino chip double-refund, room-create rollback, rename collision, fatal transport errors --- internal/game/login_race_regression_test.go | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 internal/game/login_race_regression_test.go (limited to 'internal/game/login_race_regression_test.go') diff --git a/internal/game/login_race_regression_test.go b/internal/game/login_race_regression_test.go new file mode 100644 index 0000000..6448cf5 --- /dev/null +++ b/internal/game/login_race_regression_test.go @@ -0,0 +1,77 @@ +package game + +// The off-owner login paths must never read owner-owned session fields: +// the owner goroutine replaces Session.Account at any moment (account +// republish after a rename or purge races any concurrent login), so the +// connection goroutine verifies passwords against the race-free authName +// snapshot instead. + +import ( + "testing" + + "thehouseoficarus/internal/net" +) + +// TestOffOwnerLoginDoesNotRaceAccountPublish drives the real server entry +// point through a login while a republish loop keeps replacing the +// session's cached account on the owner — the exact race a second +// authenticated session (or an admin-triggered account update) creates +// while the first session is typing its password. Run with -race. +func TestOffOwnerLoginDoesNotRaceAccountPublish(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + h.seedCharacter("alice", "alice") + + sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName} + sess.StartWriter() + defer sess.Close() + h.hub.Add(sess) + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 200; i++ { + h.g.RunOnOwner(func() { h.g.publishAccountUpdate("alice", "alice") }) + } + }() + + h.g.HandleSessionOnOwner(sess, "alice") // owner: offers the password prompt + h.g.HandleSessionOnOwner(sess, "pw") // off-owner: verifies the password + <-done + + if sess.AuthName() != "alice" { + t.Fatalf("password prompt lost its account name: %q", sess.AuthName()) + } + if sess.Account == nil || sess.Account.Name != "alice" { + t.Fatalf("login did not complete: %+v", sess.Account) + } +} + +// TestPasswordPromptRejectsEmptyAndOversizedInput keeps the documented +// fast-path behavior of the off-owner password handler after the +// authName rework: an empty line re-prompts (echo still off) and an +// oversized line is rejected without running Argon2id on it. +func TestPasswordPromptRejectsEmptyAndOversizedInput(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + + sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName} + sess.StartWriter() + defer sess.Close() + h.hub.Add(sess) + + h.g.HandleSessionOnOwner(sess, "alice") + if sess.State != net.StatePassword { + t.Fatalf("state after account name = %v, want StatePassword", sess.State) + } + + h.g.HandleSessionOnOwner(sess, "") + if sess.State != net.StatePassword { + t.Fatalf("empty password line must stay in StatePassword, got %v", sess.State) + } + + h.g.HandleSessionOnOwner(sess, string(make([]byte, 200))) + if sess.State != net.StateAccountName { + t.Fatalf("oversized password must return to StateAccountName, got %v", sess.State) + } +} -- cgit v1.2.3