Files
CosmicClash/server/api/service_test.go
T
2026-08-31 23:00:22 +01:00

725 lines
31 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
type queueBackendSpy struct{ createCalls, heartbeatCalls, cancelCalls, getCalls int }
type sessionBackendSpy struct{ calls int }
func (s *sessionBackendSpy) Authenticate(_ context.Context, sessionID, _ string, _ time.Time) (domain.Session, error) {
s.calls++
return domain.Session{SessionID: sessionID, PlayerID: "player-1"}, nil
}
type steamLoginSpy struct{ calls int }
func (s *steamLoginSpy) Authenticate(_ context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) {
s.calls++
if ticket != "valid-web-ticket" {
return domain.VerifiedIdentity{}, domain.ErrTicketRejected
}
return domain.VerifiedIdentity{PlayerID: "player-1", SteamID: "steam-1"}, nil
}
func (b *queueBackendSpy) Create(_ context.Context, playerID, ticketID, _ string, spec domain.QueueSpec, now time.Time) (domain.QueueTicket, error) {
b.createCalls++
return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, Playlist: spec.Playlist, State: domain.Queued, EnqueuedAt: now, ExpiresAt: now.Add(domain.QueueExpiryWindow)}, nil
}
func (b *queueBackendSpy) Heartbeat(_ context.Context, playerID, ticketID, _ string, revision uint64, now time.Time) (domain.QueueTicket, error) {
b.heartbeatCalls++
return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, State: domain.Queued, Revision: revision + 1, EnqueuedAt: now, ExpiresAt: now.Add(domain.QueueExpiryWindow)}, nil
}
func (b *queueBackendSpy) Cancel(_ context.Context, playerID, ticketID, _ string, revision uint64, now time.Time) (domain.QueueTicket, error) {
b.cancelCalls++
return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, State: domain.Cancelled, Revision: revision + 1, EnqueuedAt: now, ExpiresAt: now}, nil
}
func (b *queueBackendSpy) Get(_ context.Context, playerID, ticketID string, now time.Time) (domain.QueueTicket, error) {
b.getCalls++
return domain.QueueTicket{TicketID: ticketID, PlayerID: playerID, State: domain.Queued, EnqueuedAt: now, ExpiresAt: now.Add(domain.QueueExpiryWindow)}, nil
}
func TestAuthenticatedQueueAPIUsesServerCandidateAndRevisionedMutations(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-1", time.Hour, now)
if err != nil {
t.Fatal(err)
}
queue := domain.NewQueue()
service := &Service{Sessions: sessions, Queue: queue, Now: func() time.Time { return now }, Candidate: func(playerID, ticketID string) (domain.Candidate, error) {
return domain.Candidate{PlayerID: playerID, TicketID: ticketID, EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 20}}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(method, path, body string, headers map[string]string) *http.Response {
req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body))
for key, value := range headers {
req.Header.Set(key, value)
}
response, requestErr := http.DefaultClient.Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
return response
}
headers := map[string]string{"Authorization": "Bearer " + session.SessionID + ":" + token, "Idempotency-Key": "create-key-123456"}
response := request(http.MethodPost, "/v1/queue", `{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1}`, headers)
if response.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d", response.StatusCode)
}
var created queueResponse
if err := json.NewDecoder(response.Body).Decode(&created); err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if created.PlayerID != "player-1" || created.State != "QUEUED" || created.Revision != 0 {
t.Fatalf("created = %+v", created)
}
response = request(http.MethodPost, "/v1/queue/ticket-1/heartbeat", `{}`, map[string]string{"Authorization": headers["Authorization"], "Idempotency-Key": "heartbeat-key-123456", "If-Match-Revision": "0"})
if response.StatusCode != http.StatusOK {
t.Fatalf("heartbeat status = %d", response.StatusCode)
}
_ = response.Body.Close()
response = request(http.MethodPost, "/v1/queue/ticket-1/cancel", `{}`, map[string]string{"Authorization": headers["Authorization"], "Idempotency-Key": "cancel-key-123456", "If-Match-Revision": "0"})
if response.StatusCode != http.StatusConflict {
t.Fatalf("stale cancel status = %d", response.StatusCode)
}
_ = response.Body.Close()
}
func TestDocumentedContractRoutesAdaptToServiceAPI(t *testing.T) {
now := time.Unix(1000, 0).UTC()
backend := &queueBackendSpy{}
service := &Service{
SessionBackend: &sessionBackendSpy{},
QueueBackend: backend,
Now: func() time.Time { return now },
}
server := httptest.NewServer(service.Handler())
defer server.Close()
auth := "Bearer session-1:token-1"
create, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets", strings.NewReader(`{"playlist":"casual","client_build":"build-1","protocol_version":1}`))
if err != nil {
t.Fatal(err)
}
create.Header.Set("Authorization", auth)
create.Header.Set("Idempotency-Key", "contract-create-key-123456")
response, err := http.DefaultClient.Do(create)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusCreated || backend.createCalls != 1 {
t.Fatalf("create status = %d, calls = %d", response.StatusCode, backend.createCalls)
}
var ticket queueResponse
if err := json.NewDecoder(response.Body).Decode(&ticket); err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if ticket.TicketID == "" {
t.Fatal("contract adapter did not assign a ticket id")
}
heartbeat, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID+"/heartbeat", nil)
if err != nil {
t.Fatal(err)
}
heartbeat.Header.Set("Authorization", auth)
heartbeat.Header.Set("Idempotency-Key", "contract-heartbeat-key-123")
heartbeat.Header.Set("If-Match-Revision", "0")
response, err = http.DefaultClient.Do(heartbeat)
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusOK || backend.heartbeatCalls != 1 {
t.Fatalf("heartbeat status = %d, calls = %d", response.StatusCode, backend.heartbeatCalls)
}
cancel, err := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID, nil)
if err != nil {
t.Fatal(err)
}
cancel.Header.Set("Authorization", auth)
cancel.Header.Set("Idempotency-Key", "contract-cancel-key-123456")
cancel.Header.Set("If-Match-Revision", "0")
response, err = http.DefaultClient.Do(cancel)
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNoContent || backend.cancelCalls != 1 {
t.Fatalf("cancel status = %d, calls = %d", response.StatusCode, backend.cancelCalls)
}
}
func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
service := &Service{Sessions: domain.NewSessionStore(), Queue: domain.NewQueue(), Candidate: func(string, string) (domain.Candidate, error) { return domain.Candidate{}, nil }}
server := httptest.NewServer(service.Handler())
defer server.Close()
request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1,"player_id":"attacker"}`))
request.Header.Set("Idempotency-Key", "create-key-123456")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d", response.StatusCode)
}
_ = response.Body.Close()
sessionStore := domain.NewSessionStore()
session, token, _ := sessionStore.Issue("player-1", time.Hour, time.Now())
service.Sessions = sessionStore
request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1,"unknown":true}`))
request.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
request.Header.Set("Idempotency-Key", "create-key-123456")
response, err = http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown field status = %d", response.StatusCode)
}
_ = response.Body.Close()
request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":`))
request.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
request.Header.Set("Idempotency-Key", "create-key-654321")
response, err = http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("malformed body status = %d", response.StatusCode)
}
_ = response.Body.Close()
request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1}{"ticket_id":"ticket-2"}`))
request.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
request.Header.Set("Idempotency-Key", "create-key-789012")
response, err = http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("trailing JSON status = %d", response.StatusCode)
}
_ = response.Body.Close()
}
func TestQueueCreateRequiresCompatibilityMetadataAndPassesItToProvider(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-1", time.Hour, now)
if err != nil {
t.Fatal(err)
}
var got domain.QueueSpec
service := &Service{
Sessions: sessions,
Queue: domain.NewQueue(),
Now: func() time.Time { return now },
CandidateV2: func(_ string, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) {
got = spec
return domain.Candidate{PlayerID: "player-1", TicketID: ticketID, Playlist: spec.Playlist, ClientBuild: spec.ClientBuild, ProtocolVersion: spec.ProtocolVersion, EnqueuedAt: now}, nil
},
}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(body string) *http.Response {
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "create-key-123456")
response, requestErr := http.DefaultClient.Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
return response
}
response := request(`{"ticket_id":"ticket-1"}`)
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("missing metadata status = %d", response.StatusCode)
}
_ = response.Body.Close()
response = request(`{"ticket_id":"ticket-1","playlist":"invalid","client_build":"build-1","protocol_version":1}`)
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid playlist status = %d", response.StatusCode)
}
_ = response.Body.Close()
response = request(`{"ticket_id":"ticket-1","playlist":"ranked","client_build":"build-1","protocol_version":7}`)
if response.StatusCode != http.StatusCreated {
t.Fatalf("valid metadata status = %d", response.StatusCode)
}
_ = response.Body.Close()
if got.Playlist != domain.Ranked || got.ClientBuild != "build-1" || got.ProtocolVersion != 7 {
t.Fatalf("provider received %+v", got)
}
}
func TestQueueCreateRejectsCandidateMetadataMismatch(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, _ := sessions.Issue("player-1", time.Hour, now)
service := &Service{Sessions: sessions, Queue: domain.NewQueue(), Now: func() time.Time { return now }, CandidateV2: func(_ string, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) {
spec.ClientBuild = "tampered"
return domain.Candidate{PlayerID: "player-1", TicketID: ticketID, Playlist: spec.Playlist, ClientBuild: spec.ClientBuild, ProtocolVersion: spec.ProtocolVersion, EnqueuedAt: now}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"ranked","client_build":"build-1","protocol_version":1}`))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "create-key-123456")
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("mismatch status = %d", response.StatusCode)
}
}
func TestQueueCreateAPIRetriesIdenticallyAndRejectsKeyReuseWithChangedPayload(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-1", time.Hour, now)
if err != nil {
t.Fatal(err)
}
service := &Service{Sessions: sessions, Queue: domain.NewQueue(), Now: func() time.Time { return now }, Candidate: func(playerID, ticketID string) (domain.Candidate, error) {
return domain.Candidate{PlayerID: playerID, TicketID: ticketID, EnqueuedAt: now}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(body, key string) (int, queueResponse) {
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", key)
response, requestErr := http.DefaultClient.Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
defer response.Body.Close()
var decoded queueResponse
if response.StatusCode == http.StatusCreated {
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
t.Fatal(err)
}
}
return response.StatusCode, decoded
}
body := `{"ticket_id":"ticket-idempotent","playlist":"casual","client_build":"build-1","protocol_version":1}`
status, first := request(body, "idempotency-key-123456")
if status != http.StatusCreated {
t.Fatalf("first create status=%d", status)
}
status, replay := request(body, "idempotency-key-123456")
if status != http.StatusCreated || replay != first {
t.Fatalf("identical replay status=%d first=%+v replay=%+v", status, first, replay)
}
changed := `{"ticket_id":"ticket-idempotent","playlist":"casual","client_build":"build-2","protocol_version":1}`
status, _ = request(changed, "idempotency-key-123456")
if status != http.StatusConflict {
t.Fatalf("changed-payload replay status=%d, want conflict", status)
}
}
func TestQueueAPIUsesInjectedPersistentBackendWithoutCandidateProvider(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, _ := sessions.Issue("player-1", time.Hour, now)
backend := &queueBackendSpy{}
service := &Service{Sessions: sessions, QueueBackend: backend, Now: func() time.Time { return now }}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1","playlist":"ranked","client_build":"build-1","protocol_version":1}`))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "create-key-123456")
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated || backend.createCalls != 1 {
t.Fatalf("status=%d backend_calls=%d", response.StatusCode, backend.createCalls)
}
}
func TestQueueAPIDelegatesAllMutationsAndRecoveryToBackend(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, _ := sessions.Issue("player-1", time.Hour, now)
backend := &queueBackendSpy{}
service := &Service{Sessions: sessions, QueueBackend: backend, Now: func() time.Time { return now }}
server := httptest.NewServer(service.Handler())
defer server.Close()
auth := "Bearer " + session.SessionID + ":" + token
request := func(method, path, body, key, revision string) *http.Response {
req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body))
req.Header.Set("Authorization", auth)
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
if revision != "" {
req.Header.Set("If-Match-Revision", revision)
}
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return response
}
response := request(http.MethodGet, "/v1/queue/ticket-1", "", "", "")
if response.StatusCode != http.StatusOK {
t.Fatalf("get status = %d", response.StatusCode)
}
response.Body.Close()
response = request(http.MethodPost, "/v1/queue/ticket-1/heartbeat", `{}`, "heartbeat-key-123456", "0")
if response.StatusCode != http.StatusOK {
t.Fatalf("heartbeat status = %d", response.StatusCode)
}
response.Body.Close()
response = request(http.MethodPost, "/v1/queue/ticket-1/cancel", `{}`, "cancel-key-123456", "1")
if response.StatusCode != http.StatusOK {
t.Fatalf("cancel status = %d", response.StatusCode)
}
response.Body.Close()
if backend.getCalls != 1 || backend.heartbeatCalls != 1 || backend.cancelCalls != 1 {
t.Fatalf("backend calls = %+v", backend)
}
}
func TestQueueAPIUsesInjectedSessionBackend(t *testing.T) {
backend := &sessionBackendSpy{}
queue := &queueBackendSpy{}
service := &Service{SessionBackend: backend, QueueBackend: queue, Now: func() time.Time { return time.Unix(1000, 0).UTC() }}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/queue/ticket-1", nil)
req.Header.Set("Authorization", "Bearer durable-session:durable-token")
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || backend.calls != 1 || queue.getCalls != 1 {
t.Fatalf("status=%d session_calls=%d queue_calls=%d", response.StatusCode, backend.calls, queue.getCalls)
}
}
func TestSteamSessionAPIRequiresBackendVerificationAndIssuesOpaqueSession(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
provider := &steamLoginSpy{}
service := &Service{Sessions: sessions, SteamLogin: provider, Now: func() time.Time { return now }}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(body string) *http.Response {
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/session/steam", strings.NewReader(body))
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return response
}
response := request(`{"web_api_ticket":"valid-web-ticket","steam_id":"spoofed"}`)
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("extra field status = %d", response.StatusCode)
}
response.Body.Close()
response = request(`{"web_api_ticket":"invalid"}`)
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("invalid ticket status = %d", response.StatusCode)
}
response.Body.Close()
response = request(`{"web_api_ticket":"valid-web-ticket"}`)
if response.StatusCode != http.StatusOK {
t.Fatalf("valid ticket status = %d", response.StatusCode)
}
var result struct {
PlayerID string `json:"player_id"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal(err)
}
response.Body.Close()
if result.PlayerID != "player-1" || !strings.Contains(result.AccessToken, ":") || provider.calls != 2 {
t.Fatalf("session result=%+v provider_calls=%d", result, provider.calls)
}
}
func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
ownerSession, ownerToken, err := sessions.Issue("player-1", time.Hour, now)
if err != nil {
t.Fatal(err)
}
otherSession, otherToken, err := sessions.Issue("player-2", time.Hour, now)
if err != nil {
t.Fatal(err)
}
queue := domain.NewQueue()
service := &Service{Sessions: sessions, Queue: queue, Now: func() time.Time { return now }, Candidate: func(playerID, ticketID string) (domain.Candidate, error) {
return domain.Candidate{PlayerID: playerID, TicketID: ticketID, EnqueuedAt: now}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
create, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-recovery-123456","playlist":"casual","client_build":"build-1","protocol_version":1}`))
create.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
create.Header.Set("Idempotency-Key", "queue-create-recovery-123456")
response, err := http.DefaultClient.Do(create)
if err != nil || response.StatusCode != http.StatusCreated {
t.Fatalf("create status=%v err=%v", response.StatusCode, err)
}
_ = response.Body.Close()
get, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/queue/ticket-recovery-123456", nil)
get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
response, err = http.DefaultClient.Do(get)
if err != nil || response.StatusCode != http.StatusOK {
t.Fatalf("owner recovery status=%v err=%v", response.StatusCode, err)
}
_ = response.Body.Close()
get.Header.Set("Authorization", "Bearer "+otherSession.SessionID+":"+otherToken)
response, err = http.DefaultClient.Do(get)
if err != nil || response.StatusCode != http.StatusForbidden {
t.Fatalf("cross-player recovery status=%v err=%v", response.StatusCode, err)
}
_ = response.Body.Close()
service.Now = func() time.Time { return now.Add(domain.QueueExpiryWindow) }
get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
response, err = http.DefaultClient.Do(get)
if err != nil || response.StatusCode != http.StatusGone {
t.Fatalf("expired recovery status=%v err=%v", response.StatusCode, err)
}
_ = response.Body.Close()
}
func TestAuthenticatedProposalAPIUsesRevisionAndIdempotencyPolicy(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-a", time.Hour, now)
if err != nil {
t.Fatal(err)
}
proposal, err := domain.NewProposal("proposal-123456789", domain.Casual, []string{"player-a", "player-b"}, now)
if err != nil {
t.Fatal(err)
}
service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{proposal.ProposalID: &proposal}, Now: func() time.Time { return now }}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/proposals/"+proposal.ProposalID+"/accept", nil)
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "proposal-response-123456")
req.Header.Set("If-Match-Revision", "0")
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK {
t.Fatalf("proposal accept status = %d", response.StatusCode)
}
_ = response.Body.Close()
req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/proposals/"+proposal.ProposalID+"/accept", nil)
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
req.Header.Set("Idempotency-Key", "proposal-response-654321")
req.Header.Set("If-Match-Revision", "0")
response, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusConflict {
t.Fatalf("stale proposal response status = %d", response.StatusCode)
}
_ = response.Body.Close()
}
func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-a", time.Hour, now)
if err != nil {
t.Fatal(err)
}
policy, err := domain.NewTierPolicy([]domain.TierBand{{Tier: domain.RankTierBronze, MinRating: 0}, {Tier: domain.RankTierGold, MinRating: 1500}})
if err != nil {
t.Fatal(err)
}
service := &Service{
Sessions: sessions,
RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, LastSeasonID: "season-1"}},
TierPolicy: policy,
Now: func() time.Time { return now },
}
server := httptest.NewServer(service.Handler())
defer server.Close()
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/profile/ranked", nil)
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("ranked profile status = %d", response.StatusCode)
}
var body rankedProfileResponse
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-1" {
t.Fatalf("ranked profile response = %+v", body)
}
}
func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-a", time.Hour, now)
if err != nil {
t.Fatal(err)
}
called := false
service := &Service{Sessions: sessions, Now: func() time.Time { return now }, Probe: func(playerID, region string, location, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
called = true
if playerID != "player-a" || region != "EU" || string(location) != "opaque" || string(nonce) != "nonce" || !receivedAt.Equal(now) {
t.Fatalf("probe provider arguments = %q %s %q %q %v", playerID, region, location, nonce, receivedAt)
}
return domain.ProbeEvidence{OpaqueLocation: location, Nonce: nonce, IssuedAt: now, Region: region, ServerRTT: 40 * time.Millisecond}, nonce, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U="}`
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
response, err := http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusAccepted || !called {
t.Fatalf("valid probe status=%v err=%v called=%v", response.StatusCode, err, called)
}
_ = response.Body.Close()
request = `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U=","server_rtt_ms":1}`
req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request))
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
response, err = http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusBadRequest {
t.Fatalf("client RTT field status=%v err=%v", response.StatusCode, err)
}
_ = response.Body.Close()
}
func TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
owner, ownerToken, err := sessions.Issue("player-a", time.Hour, now)
if err != nil {
t.Fatal(err)
}
other, otherToken, err := sessions.Issue("player-z", time.Hour, now)
if err != nil {
t.Fatal(err)
}
proposal, err := domain.NewProposal("proposal-recovery", domain.Casual, []string{"player-a", "player-b"}, now)
if err != nil {
t.Fatal(err)
}
current := now
service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{proposal.ProposalID: &proposal}, Now: func() time.Time { return current }}
server := httptest.NewServer(service.Handler())
defer server.Close()
get := func(session domain.Session, token string) (int, proposalResponse) {
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/proposals/proposal-recovery", nil)
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
response, requestErr := http.DefaultClient.Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
defer response.Body.Close()
var body proposalResponse
if response.StatusCode == http.StatusOK {
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatal(err)
}
}
return response.StatusCode, body
}
status, recovered := get(owner, ownerToken)
if status != http.StatusOK || recovered.State != string(domain.Open) || recovered.Revision != 0 {
t.Fatalf("owner recovery status=%d body=%+v", status, recovered)
}
status, _ = get(other, otherToken)
if status != http.StatusNotFound {
t.Fatalf("non-participant recovery status=%d, want 404", status)
}
current = now.Add(domain.ProposalWindow)
status, recovered = get(owner, ownerToken)
if status != http.StatusOK || recovered.State != string(domain.Expired) || recovered.Revision != 1 {
t.Fatalf("expired recovery status=%d body=%+v", status, recovered)
}
}
func TestAssignmentRecoveryIsPlayerScopedAndRejectsExpiredOrMismatchedViews(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()
session, token, err := sessions.Issue("player-a", time.Hour, now)
if err != nil {
t.Fatal(err)
}
other, otherToken, err := sessions.Issue("player-z", time.Hour, now)
if err != nil {
t.Fatal(err)
}
current := now
service := &Service{Sessions: sessions, Now: func() time.Time { return current }, Assignment: func(_ context.Context, _ string, matchID string, _ time.Time) (AssignmentView, error) {
return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", JoinAuthorisation: "signed-join"}, nil
}}
server := httptest.NewServer(service.Handler())
defer server.Close()
get := func(path string) (int, AssignmentView) {
req, _ := http.NewRequest(http.MethodGet, server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token)
response, requestErr := http.DefaultClient.Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
defer response.Body.Close()
var view AssignmentView
if response.StatusCode == http.StatusOK {
if err := json.NewDecoder(response.Body).Decode(&view); err != nil {
t.Fatal(err)
}
}
return response.StatusCode, view
}
status, view := get("/v1/assignments/match-1")
if status != http.StatusOK || view.PlayerID != "player-a" || view.Slot != 2 {
t.Fatalf("assignment status=%d view=%+v", status, view)
}
req, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assignments/match-1", nil)
req.Header.Set("Authorization", "Bearer "+other.SessionID+":"+otherToken)
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusNotFound {
t.Fatalf("misbound assignment status=%d, want 404", response.StatusCode)
}
response.Body.Close()
status, _ = get("/v1/assignments/")
if status != http.StatusNotFound {
t.Fatalf("malformed assignment path status=%d", status)
}
current = now.Add(time.Minute)
status, _ = get("/v1/assignments/match-1")
if status != http.StatusServiceUnavailable {
t.Fatalf("expired assignment status=%d", status)
}
}