mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
196 lines
7.1 KiB
Go
196 lines
7.1 KiB
Go
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 TestPlanRankedAbandonmentsFencesGraceAndClockAnomalies(t *testing.T) {
|
|
now := time.Unix(1000, 0).UTC()
|
|
planned, err := PlanRankedAbandonments(now, []ReconnectParticipant{
|
|
{PlayerID: "within", DisconnectedAt: now.Add(-RankedReconnectGrace)},
|
|
{PlayerID: "future", DisconnectedAt: now.Add(time.Second)},
|
|
{PlayerID: "expired", DisconnectedAt: now.Add(-RankedReconnectGrace - time.Nanosecond)},
|
|
}, map[string][]time.Time{"expired": {now.Add(-time.Hour)}})
|
|
if err != nil || len(planned) != 1 || planned[0].PlayerID != "expired" || planned[0].Cooldown != 15*time.Minute || !planned[0].AbandonedAt.Equal(now) {
|
|
t.Fatalf("planned=%+v err=%v", planned, err)
|
|
}
|
|
if _, err := PlanRankedAbandonments(now, []ReconnectParticipant{{PlayerID: "duplicate", DisconnectedAt: now}, {PlayerID: "duplicate", DisconnectedAt: now}}, nil); err == nil {
|
|
t.Fatal("duplicate reconnect participant accepted")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|