Files
ruby-sea-battle/game-server/internal/ws/handler_test.go
T

320 lines
9.0 KiB
Go

package ws_test
import (
"context"
"encoding/json"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/golang-jwt/jwt/v5"
"github.com/ruby-sea-battle/game-server/internal/auth"
"github.com/ruby-sea-battle/game-server/internal/game"
"github.com/ruby-sea-battle/game-server/internal/ws"
)
var secret = []byte("integration-secret")
const publicAddr = "ws://game.example.com/ws"
func gameToken(t *testing.T, matchID string, p1, p2 int64, gameServer string, exp time.Time) string {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.GameClaims{
MatchID: matchID,
Player1ID: p1,
Player2ID: p2,
GameServer: gameServer,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(exp),
},
}).SignedString(secret)
if err != nil {
t.Fatalf("sign game token: %v", err)
}
return token
}
func userToken(t *testing.T, userID int64) string {
t.Helper()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.UserClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}).SignedString(secret)
if err != nil {
t.Fatalf("sign user token: %v", err)
}
return token
}
func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) {
t.Helper()
manager := game.NewManager(joinTimeout)
log := slog.New(slog.NewTextHandler(testWriter{t}, nil))
handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log)
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http"), manager
}
func startServer(t *testing.T, joinTimeout time.Duration) (url string) {
u, _ := startServerWithManager(t, joinTimeout)
return u
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(p []byte) (int, error) {
w.t.Log(strings.TrimSpace(string(p)))
return len(p), nil
}
func dial(t *testing.T, url string) *websocket.Conn {
t.Helper()
c, _, err := websocket.Dial(context.Background(), url, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { c.Close(websocket.StatusNormalClosure, "") })
return c
}
func join(t *testing.T, c *websocket.Conn, gt, ut string) {
t.Helper()
msg, _ := json.Marshal([]any{1, gt, ut})
if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil {
t.Fatalf("write JOIN: %v", err)
}
}
func readMsg(t *testing.T, c *websocket.Conn) []any {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, data, err := c.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var v []any
if err := json.Unmarshal(data, &v); err != nil {
t.Fatalf("unmarshal %s: %v", data, err)
}
return v
}
func TestTwoPlayersStartMatch(t *testing.T) {
url := startServer(t, time.Minute)
exp := time.Now().Add(time.Hour)
gt := gameToken(t, "m-1", 1, 2, publicAddr, exp)
c1, c2 := dial(t, url), dial(t, url)
join(t, c1, gt, userToken(t, 1))
join(t, c2, gt, userToken(t, 2))
for i, c := range []*websocket.Conn{c1, c2} {
v := readMsg(t, c)
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
t.Fatalf("player %d: unexpected message %v", i, v)
}
// ACK the state.
msg, _ := json.Marshal([]any{4, 1})
if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil {
t.Fatalf("write ACK: %v", err)
}
}
}
func TestWrongServerAddressRejected(t *testing.T) {
url := startServer(t, time.Minute)
gt := gameToken(t, "m-2", 1, 2, "ws://other.example.com/ws", time.Now().Add(time.Hour))
c := dial(t, url)
join(t, c, gt, userToken(t, 1))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := c.Read(ctx)
if err == nil {
t.Fatal("expected connection to be closed")
}
if code := websocket.CloseStatus(err); code != 4003 {
t.Fatalf("close code = %d, want 4003", code)
}
}
func TestJoinTimeoutEndsMatchAndReplay(t *testing.T) {
url := startServer(t, 200*time.Millisecond)
exp := time.Now().Add(time.Hour)
gt := gameToken(t, "m-3", 1, 2, publicAddr, exp)
c1 := dial(t, url)
join(t, c1, gt, userToken(t, 1))
v := readMsg(t, c1) // END
if v[0] != float64(2) || v[1] != float64(1) || v[2] != float64(-1) || v[3] != "join timeout exceeded" {
t.Fatalf("unexpected END: %v", v)
}
// Late join with the same token replays END and closes.
c2 := dial(t, url)
join(t, c2, gt, userToken(t, 2))
v = readMsg(t, c2)
if v[0] != float64(2) || v[2] != float64(-1) {
t.Fatalf("unexpected replayed END: %v", v)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, _, err := c2.Read(ctx); err == nil {
t.Fatal("expected connection to be closed after END replay")
}
}
func TestReconnectDuringGame(t *testing.T) {
url := startServer(t, time.Minute)
exp := time.Now().Add(time.Hour)
gt := gameToken(t, "m-4", 1, 2, publicAddr, exp)
c1, c2 := dial(t, url), dial(t, url)
join(t, c1, gt, userToken(t, 1))
join(t, c2, gt, userToken(t, 2))
readMsg(t, c1) // STATE, not acknowledged
// Simulate a dropped connection.
c1.Close(websocket.StatusAbnormalClosure, "network gone")
// Reconnect with the same tokens: unacknowledged STATE is replayed.
c1b := dial(t, url)
join(t, c1b, gt, userToken(t, 1))
v := readMsg(t, c1b)
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
t.Fatalf("unexpected replayed message: %v", v)
}
}
func TestReconnectWithExpiredTokenAllowedIfSameToken(t *testing.T) {
url := startServer(t, time.Minute)
// Short expiration: 1200ms
exp := time.Now().Add(1200 * time.Millisecond)
gt := gameToken(t, "m-reconnect-exp", 1, 2, publicAddr, exp)
c1, c2 := dial(t, url), dial(t, url)
join(t, c1, gt, userToken(t, 1))
join(t, c2, gt, userToken(t, 2))
readMsg(t, c1) // STATE
readMsg(t, c2) // STATE
// Wait for the game token to expire
time.Sleep(1300 * time.Millisecond)
// Simulate disconnect
c1.Close(websocket.StatusAbnormalClosure, "network gone")
// Reconnect with the SAME token (which is now expired)
c1b := dial(t, url)
join(t, c1b, gt, userToken(t, 1))
v := readMsg(t, c1b)
if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) {
t.Fatalf("unexpected replayed message on reconnect: %v", v)
}
// But an expired token for a non-existent match is rejected with 4003
gtExpired := gameToken(t, "m-other", 1, 2, publicAddr, time.Now().Add(-time.Hour))
cOther := dial(t, url)
join(t, cOther, gtExpired, userToken(t, 1))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := cOther.Read(ctx)
if err == nil {
t.Fatal("expected connection to be rejected")
}
if code := websocket.CloseStatus(err); code != 4003 {
t.Fatalf("close code = %d, want 4003", code)
}
}
func TestDuplicateJoinIgnored(t *testing.T) {
url := startServer(t, time.Minute)
exp := time.Now().Add(time.Hour)
gt := gameToken(t, "m-dup-join", 1, 2, publicAddr, exp)
c1, c2 := dial(t, url), dial(t, url)
join(t, c1, gt, userToken(t, 1))
join(t, c2, gt, userToken(t, 2))
readMsg(t, c1) // STATE
// Send duplicate JOIN on c1
join(t, c1, gt, userToken(t, 1))
// Send ACK after duplicate JOIN; connection must still be open and functioning
ackMsg, _ := json.Marshal([]any{4, 1})
if err := c1.Write(context.Background(), websocket.MessageText, ackMsg); err != nil {
t.Fatalf("write ACK: %v", err)
}
}
func TestNonPositiveIDsRejected(t *testing.T) {
url := startServer(t, time.Minute)
exp := time.Now().Add(time.Hour)
// Non-positive Player1ID
gtBadP1 := gameToken(t, "m-bad-p1", 0, 2, publicAddr, exp)
c := dial(t, url)
join(t, c, gtBadP1, userToken(t, 2))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := c.Read(ctx)
if err == nil || websocket.CloseStatus(err) != 4003 {
t.Fatalf("expected 4003 for player1_id = 0, got err = %v", err)
}
// Non-positive UserID
gtValid := gameToken(t, "m-valid", 1, 2, publicAddr, exp)
c2 := dial(t, url)
join(t, c2, gtValid, userToken(t, -1))
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
_, _, err = c2.Read(ctx2)
if err == nil || websocket.CloseStatus(err) != 4004 {
t.Fatalf("expected 4004 for user_id = -1, got err = %v", err)
}
}
func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) {
url, mgr := startServerWithManager(t, time.Minute)
exp := time.Now().Add(time.Hour)
gt := gameToken(t, "m-shutdown", 1, 2, publicAddr, exp)
c1, c2 := dial(t, url), dial(t, url)
join(t, c1, gt, userToken(t, 1))
join(t, c2, gt, userToken(t, 2))
readMsg(t, c1)
readMsg(t, c2)
errCh := make(chan error, 2)
for _, c := range []*websocket.Conn{c1, c2} {
go func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _, err := conn.Read(ctx)
errCh <- err
}(c)
}
mgr.Shutdown()
for i := 0; i < 2; i++ {
err := <-errCh
if err == nil {
t.Fatalf("client %d: expected connection to be closed", i)
}
if code := websocket.CloseStatus(err); code != 1001 {
t.Fatalf("client %d: close status = %d, want 1001", i, code)
}
}
}