package domain import ( "crypto/hmac" "crypto/sha256" "errors" "testing" "time" ) func testRoster(now time.Time) []JoinAuthorisation { roster := make([]JoinAuthorisation, 6) for i := range roster { roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), SteamID: string(rune('A' + i)), Slot: i, Team: i / 3, Generation: 1, ExpiresAt: now.Add(time.Hour)} } return roster } func TestRankedReconnectReclaimsWithinGraceAndFencesOldGeneration(t *testing.T) { now := time.Unix(1000, 0) r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) if err != nil { t.Fatal(err) } auth := testRoster(now)[0] if gen, err := r.Admit(auth, now); err != nil || gen != 1 { t.Fatalf("initial admit = %d, %v", gen, err) } if err := r.Disconnect("a", 1, now.Add(time.Second)); err != nil { t.Fatal(err) } if gen, err := r.Admit(auth, now.Add(time.Second+RankedReconnectGrace)); err != nil || gen != 2 { t.Fatalf("boundary reclaim = %d, %v", gen, err) } if err := r.Disconnect("a", 1, now.Add(62*time.Second)); !errors.Is(err, ErrConnectionFenced) { t.Fatalf("old connection was not fenced: %v", err) } if err := r.Disconnect("a", 2, now.Add(62*time.Second)); err != nil { t.Fatal(err) } if gen, err := r.Admit(auth, now.Add(63*time.Second)); err != nil || gen != 3 { t.Fatalf("repeated reclaim with existing authorisation = %d, %v", gen, err) } } func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { now := time.Unix(1000, 0) r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) if err != nil { t.Fatal(err) } bad := testRoster(now)[0] bad.ServerID = "server-2" if _, err := r.Admit(bad, now); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("wrong server accepted: %v", err) } wrongIdentity := testRoster(now)[0] wrongIdentity.SteamID = "steam-attacker" if _, err := r.Admit(wrongIdentity, now); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("wrong SteamID accepted: %v", err) } if _, err := r.Admit(testRoster(now)[0], now); err != nil { t.Fatal(err) } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) } if _, err := r.Admit(testRoster(now)[0], now.Add(RankedReconnectGrace+time.Nanosecond)); !errors.Is(err, ErrReconnectExpired) { t.Fatalf("expired reclaim error = %v", err) } } func TestRankedReconnectRejectsDuplicateAndTimeReversedLifecycle(t *testing.T) { now := time.Unix(1000, 0) r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) if err != nil { t.Fatal(err) } auth := testRoster(now)[0] if err := r.Disconnect("a", 1, now); !errors.Is(err, ErrConnectionFenced) { t.Fatalf("disconnect before admission error = %v", err) } if _, err := r.Admit(auth, time.Time{}); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("zero-time admission error = %v", err) } if _, err := r.Admit(auth, now); err != nil { t.Fatal(err) } if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrConnectionFenced) { t.Fatalf("duplicate active admission error = %v", err) } if err := r.Disconnect("a", 1, now.Add(2*time.Second)); err != nil { t.Fatal(err) } if err := r.Disconnect("a", 1, now.Add(30*time.Second)); !errors.Is(err, ErrConnectionFenced) { t.Fatalf("duplicate disconnect error = %v", err) } if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("time-reversed reclaim error = %v", err) } } func TestRankedRosterRejectsDuplicateSlots(t *testing.T) { now := time.Unix(1000, 0) roster := testRoster(now) roster[1].Slot = roster[0].Slot if _, err := NewRankedConnections("match-1", "server-1", "v1", roster); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("duplicate slot accepted: %v", err) } } func TestRankedRosterRejectsTeamSlotMismatch(t *testing.T) { now := time.Unix(1000, 0) roster := testRoster(now) roster[3].Team = 0 if _, err := NewRankedConnections("match-1", "server-1", "v1", roster); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("team/slot mismatch accepted: %v", err) } } func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) { now := time.Unix(1000, 0) r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) if err != nil { t.Fatal(err) } if _, err := r.Admit(testRoster(now)[0], now); err != nil { t.Fatal(err) } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) } history := map[string][]time.Time{"a": {now.Add(-6 * 24 * time.Hour), now.Add(-time.Hour), now.Add(-8 * 24 * time.Hour)}} got := r.ExpireGrace(now.Add(RankedReconnectGrace+time.Second), history) if len(got) != 1 || got[0].PlayerID != "a" || got[0].Cooldown != time.Hour { t.Fatalf("unexpected abandonment: %+v", got) } if again := r.ExpireGrace(now.Add(2*time.Minute), history); len(again) != 0 { t.Fatalf("abandonment repeated: %+v", again) } } func TestSignedJoinAuthorisationBindsEveryClaimBeforeReclaim(t *testing.T) { now := time.Unix(1000, 0).UTC() r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) if err != nil { t.Fatal(err) } key := []byte("test-signing-key") sign := func(payload []byte) ([]byte, error) { mac := hmac.New(sha256.New, key) _, _ = mac.Write(payload) return mac.Sum(nil), nil } verify := func(payload, signature []byte) bool { expected, _ := sign(payload) return hmac.Equal(expected, signature) } signed, err := SignJoinAuthorisation(testRoster(now)[0], sign) if err != nil { t.Fatal(err) } if gen, err := r.AdmitSigned(signed, verify, now); err != nil || gen != 1 { t.Fatalf("signed initial admit = %d, %v", gen, err) } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) } tampered := signed tampered.Authorisation.Slot = 1 if _, err := r.AdmitSigned(tampered, verify, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("tampered slot accepted: %v", err) } if _, err := r.AdmitSigned(signed, func([]byte, []byte) bool { return false }, now.Add(RankedReconnectGrace)); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("unverified signature accepted: %v", err) } if gen, err := r.AdmitSigned(signed, verify, now.Add(RankedReconnectGrace)); err != nil || gen != 2 { t.Fatalf("signed reclaim = %d, %v", gen, err) } }