package api import ( "bufio" "bytes" "context" "encoding/binary" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/observability" ) type queueBackendSpy struct{ createCalls, heartbeatCalls, cancelCalls, getCalls int } func TestQueueResponseCarriesRecoveredMatchIdentity(t *testing.T) { response := toQueueResponse(domain.QueueTicket{TicketID: "ticket-1234567890", PlayerID: "player-1234567890", ProposalID: "proposal-1234567890", MatchID: "match-1234567890", State: domain.AssignmentReady}) if response.ProposalID != "proposal-1234567890" { t.Fatalf("queue response proposal ID = %q", response.ProposalID) } if response.MatchID != "match-1234567890" { t.Fatalf("queue response match ID = %q", response.MatchID) } } type candidateIndexSpy struct { upsertCalls, removeCalls int upsertErr, removeErr error last domain.Candidate } type probeRecorderSpy struct { calls int err error last struct { player, region string rtt time.Duration } } type resultSubmitterSpy struct { calls int err error key string result domain.MatchResult } type serverRegistrarSpy struct { calls int binding domain.WorkloadBinding protocol int assignmentReady bool err error } type serverShutdownerSpy struct { calls int binding domain.WorkloadBinding reason string key string err error } type serverConnectionSpy struct { connectCalls int disconnectCalls int binding domain.WorkloadBinding playerID string key string expectedGeneration uint64 generation uint64 err error } func (s *serverConnectionSpy) ClaimPlayerConnection(_ context.Context, binding domain.WorkloadBinding, playerID string, expectedGeneration uint64, key string, _ time.Time) (uint64, error) { s.connectCalls++ s.binding, s.playerID, s.expectedGeneration, s.key = binding, playerID, expectedGeneration, key return expectedGeneration + 1, s.err } func (s *serverConnectionSpy) RecordPlayerDisconnected(_ context.Context, binding domain.WorkloadBinding, playerID string, generation uint64, key string, _ time.Time) error { s.disconnectCalls++ s.binding, s.playerID, s.generation, s.key = binding, playerID, generation, key return s.err } func (s *serverShutdownerSpy) ShutdownServer(_ context.Context, binding domain.WorkloadBinding, reason, key string, _ time.Time) error { s.calls++ s.binding, s.reason, s.key = binding, reason, key return s.err } func (s *serverRegistrarSpy) RegisterServer(_ context.Context, binding domain.WorkloadBinding, protocol int, assignmentReady bool, _ string, _ time.Time) error { s.calls++ s.binding, s.protocol, s.assignmentReady = binding, protocol, assignmentReady return s.err } type proposalPromoterSpy struct { calls int proposal domain.Proposal err error } func (p *proposalPromoterSpy) Promote(_ context.Context, proposal domain.Proposal, _ time.Time) error { p.calls++ p.proposal = proposal return p.err } func (r *resultSubmitterSpy) SubmitResult(_ context.Context, key string, result domain.MatchResult, _ domain.WorkloadBinding, _ []byte, _ time.Time) error { r.calls++ r.key, r.result = key, result return r.err } func (p *probeRecorderSpy) RecordProbe(_ context.Context, player, region string, rtt time.Duration, _ time.Time) error { p.calls++ p.last.player, p.last.region, p.last.rtt = player, region, rtt return p.err } func (i *candidateIndexSpy) Upsert(_ context.Context, candidate domain.Candidate) error { i.upsertCalls++ i.last = candidate return i.upsertErr } func (i *candidateIndexSpy) Remove(_ context.Context, _ string) error { i.removeCalls++ return i.removeErr } type sessionBackendSpy struct{ calls int } type proposalBackendSpy struct { proposal domain.Proposal calls int mutations int } func (b *proposalBackendSpy) Respond(_ context.Context, playerID, _ string, key string, accept bool, revision uint64, now time.Time) (domain.Proposal, error) { b.mutations++ updated, err := b.proposal.Respond(playerID, key, accept, revision, now) if err == nil { b.proposal = updated } return updated, err } func (b *proposalBackendSpy) Get(_ context.Context, playerID, _ string, _ time.Time) (domain.Proposal, error) { b.calls++ if !b.proposal.HasParticipant(playerID) { return domain.Proposal{}, domain.ErrNotParticipant } return b.proposal, nil } 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 TestDocumentedContractRoutesRejectNonOpaqueResourceIDs(t *testing.T) { service := &Service{} server := httptest.NewServer(service.Handler()) defer server.Close() request, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets", strings.NewReader(`{"ticket_id":"short","playlist":"casual","client_build":"build-1","protocol_version":1}`)) if err != nil { t.Fatal(err) } if response, requestErr := http.DefaultClient.Do(request); requestErr != nil { t.Fatal(requestErr) } else { if response.StatusCode != http.StatusBadRequest { t.Fatalf("short supplied ticket id status = %d, want 400", response.StatusCode) } response.Body.Close() } paths := []string{ "/api/v1/queue/tickets/short/heartbeat", "/api/v1/proposals/proposal/unsafe/accept", "/api/v1/assignments/match/unsafe", "/api/v1/servers/server/unsafe/result", } for _, path := range paths { request, err := http.NewRequest(http.MethodGet, server.URL+path, nil) if err != nil { t.Fatal(err) } response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } if response.StatusCode != http.StatusNotFound { t.Fatalf("%s status = %d, want 404", path, response.StatusCode) } response.Body.Close() } } func TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents(t *testing.T) { service := &Service{SessionBackend: &sessionBackendSpy{}} server := httptest.NewServer(service.Handler()) defer server.Close() invalid, err := http.NewRequest(http.MethodGet, server.URL+"/v1/events", nil) if err != nil { t.Fatal(err) } invalid.Header.Set("Upgrade", "websocket") invalid.Header.Set("Connection", "Upgrade") invalid.Header.Set("Sec-WebSocket-Key", "not-a-websocket-key") invalid.Header.Set("Authorization", "Bearer session-1:token-1") invalidResponse, err := server.Client().Do(invalid) if err != nil { t.Fatal(err) } _ = invalidResponse.Body.Close() if invalidResponse.StatusCode != http.StatusBadRequest { t.Fatalf("invalid handshake status = %d", invalidResponse.StatusCode) } connection, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://")) if err != nil { t.Fatal(err) } defer connection.Close() _, err = io.WriteString(connection, "GET /v1/events HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nAuthorization: Bearer session-1:token-1\r\n\r\n") if err != nil { t.Fatal(err) } reader := bufio.NewReader(connection) status, err := reader.ReadString('\n') if err != nil { t.Fatal(err) } if !strings.Contains(status, "101 Switching Protocols") { t.Fatalf("handshake status = %q", status) } for { line, err := reader.ReadString('\n') if err != nil { t.Fatal(err) } if line == "\r\n" { break } } time.Sleep(10 * time.Millisecond) if err := service.PublishControlPlaneEvent(ControlPlaneEvent{Event: "state_changed", Revision: 1, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1000, 0).UTC(), State: "QUEUED", PlayerID: "player-1"}); err != nil { t.Fatal(err) } if err := service.PublishControlPlaneEvent(ControlPlaneEvent{Event: "state_changed", Revision: 2, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1001, 0).UTC(), State: "PROPOSED", PlayerID: "player-2"}); err != nil { t.Fatal(err) } first, err := readServerWebSocketFrame(reader) if err != nil { t.Fatal(err) } var event ControlPlaneEvent if err := json.Unmarshal(first, &event); err != nil { t.Fatal(err) } if event.PlayerID != "" || event.Revision != 1 || event.ResourceID != "ticket-1234567890123456" || event.State != "QUEUED" { t.Fatalf("event = %+v", event) } } func TestServerRosterRequiresWorkloadBindingAndReturnsRawSignedEnvelopes(t *testing.T) { now := time.Unix(1000, 0).UTC() service := &Service{ Now: func() time.Time { return now }, WorkloadVerify: func(token string, at time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" || !at.Equal(now) { t.Fatal("unexpected workload verification input") } return domain.WorkloadBinding{ServerID: "server-1", MatchID: "match-1", AllocationID: "allocation-1"}, nil }, Roster: func(_ context.Context, binding domain.WorkloadBinding, at time.Time) ([][]byte, error) { if binding.ServerID != "server-1" || binding.MatchID != "match-1" || !at.Equal(now) { t.Fatal("unexpected roster binding") } return [][]byte{[]byte(`{"authorisation":{"player_id":"player-1","expires_at":"1970-01-01T00:33:20Z"},"signature":"sig"}`)}, nil }, } server := httptest.NewServer(service.Handler()) defer server.Close() request, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/servers/server-1/roster", nil) request.Header.Set("Authorization", "Bearer workload-token") response, err := server.Client().Do(request) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("roster status=%d", response.StatusCode) } var roster []json.RawMessage if err := json.NewDecoder(response.Body).Decode(&roster); err != nil { t.Fatal(err) } if len(roster) != 1 || !bytes.Contains(roster[0], []byte(`"player_id":"player-1"`)) { t.Fatalf("roster=%s", roster[0]) } } func readServerWebSocketFrame(reader *bufio.Reader) ([]byte, error) { first, err := reader.ReadByte() if err != nil { return nil, err } second, err := reader.ReadByte() if err != nil { return nil, err } if first&0x0f != 0x1 || second&0x80 != 0 { return nil, errors.New("unexpected server websocket frame") } length := int(second & 0x7f) if length == 126 { var extended uint16 if err := binary.Read(reader, binary.BigEndian, &extended); err != nil { return nil, err } length = int(extended) } payload := make([]byte, length) _, err = io.ReadFull(reader, payload) return payload, err } func TestEventHubClosesSlowSubscribersExactlyOnce(t *testing.T) { hub := newEventHub() subscriber := hub.subscribe("player-1") event := ControlPlaneEvent{Event: "state_changed", Revision: 1, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1000, 0).UTC(), State: "QUEUED", PlayerID: "player-1"} for i := 0; i < eventQueueCapacity; i++ { if err := hub.publish(event); err != nil { t.Fatal(err) } } if err := hub.publish(event); err != nil { t.Fatal(err) } for { _, open := <-subscriber.queue if !open { break } } hub.unsubscribe(subscriber) } func TestEventHubRejectsEventsOutsideTheV1Vocabulary(t *testing.T) { hub := newEventHub() base := ControlPlaneEvent{Revision: 1, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1000, 0).UTC(), PlayerID: "player-1"} invalid := []ControlPlaneEvent{ {Event: "unknown", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "state_changed", State: "QUEUED", Revision: base.Revision, ResourceID: "short", OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "state_changed", State: "QUEUED", Revision: base.Revision, ResourceID: "ticket-1234567890/unsafe", OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "state_changed", State: "NOT_A_STATE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "proposal_changed", State: "LIVE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "assignment_changed", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "assignment_changed", MatchID: "match-1", ServerID: "server-1", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "assignment_changed", MatchID: "match_1234567890", ServerID: "server/unsafe", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "error", Code: "SECRET_LEAK", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, } for _, event := range invalid { if err := hub.publish(event); err == nil { t.Fatalf("invalid event was accepted: %+v", event) } } } func TestStateChangingAPIActionsPublishTargetedEvents(t *testing.T) { now := time.Unix(1000, 0).UTC() backend := &queueBackendSpy{} service := &Service{SessionBackend: &sessionBackendSpy{}, QueueBackend: backend, Now: func() time.Time { return now }, Proposals: make(map[string]*domain.Proposal)} subscriber := service.getEventHub().subscribe("player-1") defer service.getEventHub().unsubscribe(subscriber) create := httptest.NewRequest(http.MethodPost, "/v1/queue", strings.NewReader(`{"ticket_id":"ticket-1234567890123456","playlist":"casual","client_build":"build-1","protocol_version":1}`)) create.Header.Set("Authorization", "Bearer session-1:token-1") create.Header.Set("Idempotency-Key", "create-event-key-123456") createRecorder := httptest.NewRecorder() service.queueCreate(createRecorder, create) if createRecorder.Code != http.StatusCreated { t.Fatalf("create status = %d", createRecorder.Code) } var queueEvent ControlPlaneEvent if err := json.Unmarshal(<-subscriber.queue, &queueEvent); err != nil { t.Fatal(err) } if queueEvent.Event != "state_changed" || queueEvent.ResourceID != "ticket-1234567890123456" || queueEvent.PlayerID != "" { t.Fatalf("queue event = %+v", queueEvent) } proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Casual, []string{"player-1", "player-2"}, now) if err != nil { t.Fatal(err) } service.Proposals[proposal.ProposalID] = &proposal respond := httptest.NewRequest(http.MethodPost, "/v1/proposals/"+proposal.ProposalID+"/accept", nil) respond.Header.Set("Authorization", "Bearer session-1:token-1") respond.Header.Set("Idempotency-Key", "proposal-event-key-123456") respond.Header.Set("If-Match-Revision", "0") respondRecorder := httptest.NewRecorder() service.proposalMutation(respondRecorder, respond) if respondRecorder.Code != http.StatusOK { t.Fatalf("proposal status = %d", respondRecorder.Code) } var proposalEvent ControlPlaneEvent if err := json.Unmarshal(<-subscriber.queue, &proposalEvent); err != nil { t.Fatal(err) } if proposalEvent.Event != "proposal_changed" || proposalEvent.ResourceID != proposal.ProposalID || proposalEvent.State != "OPEN" { t.Fatalf("proposal event = %+v", proposalEvent) } } func TestFinalProposalAcceptancePromotesDurableMatchAndFailsRetryably(t *testing.T) { now := time.Unix(1000, 0).UTC() proposal, err := domain.NewProposal("proposal-promote-123456", domain.Casual, []string{"player-1", "player-2"}, now) if err != nil { t.Fatal(err) } backend := &proposalBackendSpy{proposal: proposal} promoter := &proposalPromoterSpy{} sessions := domain.NewSessionStore() session1, token1, err := sessions.Issue("player-1", time.Hour, now) if err != nil { t.Fatal(err) } session2, token2, err := sessions.Issue("player-2", time.Hour, now) if err != nil { t.Fatal(err) } service := &Service{Sessions: sessions, ProposalBackend: backend, ProposalPromoter: promoter, Now: func() time.Time { return now }} respond := func(credential, key, revision string) int { req := httptest.NewRequest(http.MethodPost, "/v1/proposals/"+proposal.ProposalID+"/accept", nil) req.Header.Set("Authorization", "Bearer "+credential) req.Header.Set("Idempotency-Key", key) req.Header.Set("If-Match-Revision", revision) recorder := httptest.NewRecorder() service.proposalMutation(recorder, req) return recorder.Code } credential1 := session1.SessionID + ":" + token1 credential2 := session2.SessionID + ":" + token2 if status := respond(credential1, "proposal-promote-first", "0"); status != http.StatusOK || promoter.calls != 0 { t.Fatalf("first acceptance status/calls = %d/%d", status, promoter.calls) } if status := respond(credential2, "proposal-promote-final", "1"); status != http.StatusOK || promoter.calls != 1 || promoter.proposal.State != domain.Accepted { t.Fatalf("final acceptance status/promoter = %d/%+v", status, promoter) } promoter.err = errors.New("database unavailable") // A duplicate response is replayed by the durable proposal backend and // retries promotion instead of asking the player to accept again. if status := respond(credential2, "proposal-promote-final", "1"); status != http.StatusServiceUnavailable || promoter.calls != 2 { t.Fatalf("promotion retry status/calls = %d/%d", status, promoter.calls) } } func TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped(t *testing.T) { now := time.Unix(1000, 0).UTC() proposal, err := domain.NewProposal("proposal-1234567890123456", domain.Casual, []string{"player-1", "player-2"}, now) if err != nil { t.Fatal(err) } backend := &proposalBackendSpy{proposal: proposal} sessions := domain.NewSessionStore() participantSession, participantToken, err := sessions.Issue("player-1", time.Hour, now) if err != nil { t.Fatal(err) } outsiderSession, outsiderToken, err := sessions.Issue("outsider", time.Hour, now) if err != nil { t.Fatal(err) } service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{}, ProposalBackend: backend, Now: func() time.Time { return now }} server := httptest.NewServer(service.Handler()) defer server.Close() get := func(session domain.Session, token string) int { request, err := http.NewRequest(http.MethodGet, server.URL+"/v1/proposals/"+proposal.ProposalID, nil) if err != nil { t.Fatal(err) } request.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) response, err := server.Client().Do(request) if err != nil { t.Fatal(err) } _ = response.Body.Close() return response.StatusCode } if status := get(participantSession, participantToken); status != http.StatusOK { t.Fatalf("participant recovery status = %d", status) } respond, err := http.NewRequest(http.MethodPost, server.URL+"/v1/proposals/"+proposal.ProposalID+"/accept", nil) if err != nil { t.Fatal(err) } respond.Header.Set("Authorization", "Bearer "+participantSession.SessionID+":"+participantToken) respond.Header.Set("Idempotency-Key", "proposal-durable-response-123456") respond.Header.Set("If-Match-Revision", "0") response, err := server.Client().Do(respond) if err != nil { t.Fatal(err) } _ = response.Body.Close() if response.StatusCode != http.StatusOK || backend.mutations != 1 { t.Fatalf("durable response status = %d, mutations = %d", response.StatusCode, backend.mutations) } if status := get(outsiderSession, outsiderToken); status != http.StatusNotFound { t.Fatalf("outsider recovery status = %d", status) } if backend.calls != 2 { t.Fatalf("durable backend calls = %d", backend.calls) } } 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 TestQueueAPIProjectsSuccessfulMutationsWithoutMakingRedisRequired(t *testing.T) { now := time.Unix(1000, 0).UTC() sessions := domain.NewSessionStore() session, token, _ := sessions.Issue("player-1", time.Hour, now) backend := &queueBackendSpy{} index := &candidateIndexSpy{upsertErr: errors.New("redis unavailable"), removeErr: errors.New("redis unavailable")} service := &Service{Sessions: sessions, QueueBackend: backend, CandidateIndex: index, Now: func() time.Time { return now }} server := httptest.NewServer(service.Handler()) defer server.Close() auth := "Bearer " + session.SessionID + ":" + token request := func(method, path, key, revision string) *http.Response { req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(`{"ticket_id":"ticket-1","playlist":"ranked","client_build":"build-1","protocol_version":1}`)) 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.MethodPost, "/v1/queue", "create-key-123456", "") if response.StatusCode != http.StatusCreated { t.Fatalf("create status=%d", response.StatusCode) } response.Body.Close() if index.upsertCalls != 1 { t.Fatalf("upsert calls=%d", index.upsertCalls) } response = request(http.MethodPost, "/v1/queue/ticket-1/cancel", "cancel-key-123456", "0") if response.StatusCode != http.StatusOK { t.Fatalf("cancel status=%d", response.StatusCode) } response.Body.Close() if index.removeCalls != 1 { t.Fatalf("remove calls=%d", index.removeCalls) } } 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 TestReadOnlyAPIsEmitLifecycleEvents(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) } queue := domain.NewQueue() if _, err := queue.Create("player-a", "ticket-read-123456", "create-read-123456", domain.Candidate{PlayerID: "player-a", TicketID: "ticket-read-123456", Playlist: domain.Casual, EnqueuedAt: now}, now); err != nil { t.Fatal(err) } proposal, err := domain.NewProposal("proposal-read-123456", domain.Casual, []string{"player-a", "player-b"}, now) if err != nil { t.Fatal(err) } events := make([]observability.Event, 0) service := &Service{ Sessions: sessions, Queue: queue, Proposals: map[string]*domain.Proposal{proposal.ProposalID: &proposal}, RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1500, RD: 100, Volatility: 0.06}}}, TierPolicy: domain.DefaultTierPolicy(), Now: func() time.Time { return now }, Assignment: func(_ context.Context, playerID, matchID string, _ time.Time) (AssignmentView, error) { return AssignmentView{MatchID: matchID, PlayerID: playerID, ServerID: "server-read", Slot: 0, ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:30001", JoinAuthorisation: "join-token", ExpiresAt: now.Add(time.Minute)}, nil }, Log: func(event observability.Event) { events = append(events, event) }, } server := httptest.NewServer(service.Handler()) defer server.Close() auth := "Bearer " + session.SessionID + ":" + token for _, path := range []string{"/v1/queue/ticket-read-123456", "/v1/proposals/proposal-read-123456", "/v1/assignments/match-read-123456", "/api/v1/profile", "/v1/profile/ranked"} { req, _ := http.NewRequest(http.MethodGet, server.URL+path, nil) req.Header.Set("Authorization", auth) response, requestErr := http.DefaultClient.Do(req) if requestErr != nil { t.Fatal(requestErr) } if response.StatusCode != http.StatusOK { t.Fatalf("GET %s status = %d", path, response.StatusCode) } response.Body.Close() } seen := map[string]bool{} for _, event := range events { seen[event.Event] = true } for _, eventName := range []string{"queue_get", "proposal_get", "assignment_get", "profile_get", "ranked_profile_get"} { if !seen[eventName] { t.Fatalf("read event %q missing from %+v", eventName, events) } } } 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, CurrentSeasonID: "season-current", CurrentSeasonEndsAt: now.Add(48 * time.Hour), 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-current" || body.SeasonEndsAt != "1970-01-03T00:16:40Z" { t.Fatalf("ranked profile response = %+v", body) } } func TestServerResultAPIRequiresBoundWorkloadAndDelegatesDurableSubmission(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{MatchID: "match-1", ServerID: "server-1"} submitter := &resultSubmitterSpy{} service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, at time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" || !at.Equal(now) { t.Fatalf("verifier input=%q %v", token, at) } return binding, nil }, ResultSubmitter: submitter} server := httptest.NewServer(service.Handler()) defer server.Close() body := `{"match_id":"match-1","result_nonce":"nonce-1234567890","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}` req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/result", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "result-key-123456") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusAccepted { t.Fatalf("status=%v err=%v", response.StatusCode, err) } response.Body.Close() if submitter.calls != 1 || submitter.key != "result-key-123456" || submitter.result.Team0Score != 3 { t.Fatalf("submission=%+v calls=%d", submitter, submitter.calls) } req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-2/result", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "result-key-123456") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong server status=%v err=%v", response.StatusCode, err) } response.Body.Close() } func TestContractServerRoutesAdaptTwoSegmentPaths(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match_1234567890", ServerID: "server_123456789"} submitter := &resultSubmitterSpy{} registrar := &serverRegistrarSpy{} service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" { return domain.WorkloadBinding{}, errors.New("bad token") } return binding, nil }, ResultSubmitter: submitter, ServerRegistrar: registrar} server := httptest.NewServer(service.Handler()) defer server.Close() registerBody := `{"match_id":"match_1234567890","protocol_version":1,"image_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assignment_ready":false}` req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/servers/server_123456789/register", strings.NewReader(registerBody)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "contract-register-key-1") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusNoContent || registrar.calls != 1 { t.Fatalf("register status=%v err=%v calls=%d", response.StatusCode, err, registrar.calls) } response.Body.Close() resultBody := `{"match_id":"match_1234567890","result_nonce":"nonce-1234567890","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}` req, _ = http.NewRequest(http.MethodPost, server.URL+"/api/v1/servers/server_123456789/result", strings.NewReader(resultBody)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "contract-result-key-123") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusAccepted || submitter.calls != 1 { t.Fatalf("result status=%v err=%v calls=%d", response.StatusCode, err, submitter.calls) } response.Body.Close() } // TestServerMutationLoggingNeverLeaksRequestSecrets is a secret canary: it // drives the register and result routes end to end with realistic-looking // bearer tokens and a result nonce, captures every event actually emitted // through Service.Log during those real requests, and asserts the literal // secret values never appear anywhere in the encoded output -- not just that // observability.redact() strips a synthetic value under a known key name (see // TestEncodeCorrelatesStagesAndRedactsNestedCredentials in the observability // package for that narrower unit test). func TestServerMutationLoggingNeverLeaksRequestSecrets(t *testing.T) { const bearerToken = "wl-canary-secret-do-not-log-9f8e7d6c5b4a" const resultNonce = "nonce-canary-secret-value-1a2b3c4d5e6f" now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} registrar := &serverRegistrarSpy{} submitter := &resultSubmitterSpy{} var captured [][]byte service := &Service{ Now: func() time.Time { return now }, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) { if token != bearerToken { return domain.WorkloadBinding{}, errors.New("bad token") } return binding, nil }, ServerRegistrar: registrar, ResultSubmitter: submitter, Log: func(event observability.Event) { payload, err := observability.Encode(event) if err != nil { t.Fatalf("encode event: %v", err) } captured = append(captured, payload) }, } server := httptest.NewServer(service.Handler()) defer server.Close() registerBody := `{"match_id":"match-1","protocol_version":1,"image_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assignment_ready":true}` req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/register", strings.NewReader(registerBody)) req.Header.Set("Authorization", "Bearer "+bearerToken) req.Header.Set("Idempotency-Key", "canary-register-key-1") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusNoContent { t.Fatalf("register status=%v err=%v", response.StatusCode, err) } response.Body.Close() resultBody := fmt.Sprintf(`{"match_id":"match-1","result_nonce":%q,"score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}`, resultNonce) req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/result", strings.NewReader(resultBody)) req.Header.Set("Authorization", "Bearer "+bearerToken) req.Header.Set("Idempotency-Key", "canary-result-key-1234") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusAccepted { t.Fatalf("result status=%v err=%v", response.StatusCode, err) } response.Body.Close() // An unauthorized attempt must also log nothing sensitive -- it's the one // call site handling a token that never even verified successfully. req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/register", strings.NewReader(registerBody)) req.Header.Set("Authorization", "Bearer wrong-"+bearerToken) req.Header.Set("Idempotency-Key", "canary-register-key-2") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthorized register status=%v err=%v", response.StatusCode, err) } response.Body.Close() if len(captured) == 0 { t.Fatal("no events were logged; the canary can't prove anything") } all := string(bytes.Join(captured, []byte("\n"))) if strings.Contains(all, bearerToken) { t.Fatalf("bearer token leaked into logged events: %s", all) } if strings.Contains(all, resultNonce) { t.Fatalf("result nonce leaked into logged events: %s", all) } } func TestQueueAndProposalMutationsLogLifecycleEvents(t *testing.T) { now := time.Unix(1000, 0).UTC() queueBackend := &queueBackendSpy{} proposal, err := domain.NewProposal("proposal-1", domain.Casual, []string{"player-1", "player-2"}, now) if err != nil { t.Fatal(err) } proposalBackend := &proposalBackendSpy{proposal: proposal} var captured []observability.Event service := &Service{ SessionBackend: &sessionBackendSpy{}, QueueBackend: queueBackend, ProposalBackend: proposalBackend, Now: func() time.Time { return now }, Log: func(event observability.Event) { captured = append(captured, event) }, } server := httptest.NewServer(service.Handler()) defer server.Close() auth := "Bearer session-1:token-1" request := func(method, path, body string, headers map[string]string) *http.Response { req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body)) req.Header.Set("Authorization", auth) for key, value := range headers { req.Header.Set(key, value) } response, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } return response } create := request(http.MethodPost, "/v1/queue", `{"ticket_id":"ticket-1","playlist":"casual","client_build":"build-1","protocol_version":1}`, map[string]string{"Idempotency-Key": "log-create-key-123456"}) if create.StatusCode != http.StatusCreated { t.Fatalf("create status = %d", create.StatusCode) } create.Body.Close() heartbeat := request(http.MethodPost, "/v1/queue/ticket-1/heartbeat", `{}`, map[string]string{"Idempotency-Key": "log-heartbeat-key-123456", "If-Match-Revision": "0"}) if heartbeat.StatusCode != http.StatusOK { t.Fatalf("heartbeat status = %d", heartbeat.StatusCode) } heartbeat.Body.Close() cancel := request(http.MethodPost, "/v1/queue/ticket-1/cancel", `{}`, map[string]string{"Idempotency-Key": "log-cancel-key-123456", "If-Match-Revision": "0"}) if cancel.StatusCode != http.StatusOK { t.Fatalf("cancel status = %d", cancel.StatusCode) } cancel.Body.Close() respond := request(http.MethodPost, "/v1/proposals/proposal-1/accept", `{}`, map[string]string{"Idempotency-Key": "log-respond-key-123456", "If-Match-Revision": "0"}) if respond.StatusCode != http.StatusOK { t.Fatalf("proposal accept status = %d", respond.StatusCode) } respond.Body.Close() // Same stale revision again -- the real domain.Proposal.Respond behind // proposalBackendSpy fences this for real, unlike the dumb queue spy // above, so this proves the rejection path logs too. staleRespond := request(http.MethodPost, "/v1/proposals/proposal-1/accept", `{}`, map[string]string{"Idempotency-Key": "log-respond-key-234567", "If-Match-Revision": "0"}) if staleRespond.StatusCode != http.StatusConflict { t.Fatalf("stale proposal accept status = %d", staleRespond.StatusCode) } staleRespond.Body.Close() want := []struct{ event, id, stage string }{ {"queue_create", "ticket-1", "queued"}, {"queue_heartbeat", "ticket-1", "queued"}, {"queue_cancel", "ticket-1", "cancelled"}, {"proposal_response", "proposal-1", "open"}, {"proposal_response", "proposal-1", "rejected"}, } if len(captured) != len(want) { t.Fatalf("captured %d events, want %d: %+v", len(captured), len(want), captured) } for i, w := range want { got := captured[i] gotID := got.QueueID if got.Event == "proposal_response" { gotID = got.ProposalID } if got.Event != w.event || gotID != w.id || got.Stage != w.stage { t.Fatalf("event[%d] = %+v, want {%s %s %s}", i, got, w.event, w.id, w.stage) } } } func TestServerRegistrationAPIRequiresBoundWorkloadAndValidDigest(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} registrar := &serverRegistrarSpy{} service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" { return domain.WorkloadBinding{}, errors.New("bad token") } return binding, nil }, ServerRegistrar: registrar} server := httptest.NewServer(service.Handler()) defer server.Close() body := `{"match_id":"match-1","protocol_version":1,"image_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assignment_ready":false}` req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/register", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "register-key-123456") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusNoContent || registrar.calls != 1 || registrar.binding != binding || registrar.protocol != 1 || registrar.assignmentReady { t.Fatalf("status=%v err=%v registrar=%+v", response.StatusCode, err, registrar) } response.Body.Close() body = `{"match_id":"match-1","protocol_version":1,"image_digest":"bad","assignment_ready":false}` req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/register", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "register-key-123456") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusUnprocessableEntity || registrar.calls != 1 { t.Fatalf("invalid registration status=%v err=%v calls=%d", response.StatusCode, err, registrar.calls) } response.Body.Close() } func TestServerMutationConflictsAreExportedAsADistinctPrometheusCounter(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} registrar := &serverRegistrarSpy{err: domain.ErrConflict} metrics := observability.NewMetrics() service := &Service{Now: func() time.Time { return now }, Metrics: metrics, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" { return domain.WorkloadBinding{}, errors.New("bad token") } return binding, nil }, ServerRegistrar: registrar} server := httptest.NewServer(service.Handler()) defer server.Close() body := `{"match_id":"match-1","protocol_version":1,"image_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assignment_ready":false}` req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/register", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "register-key-123456") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusConflict { t.Fatalf("status=%v err=%v", response.StatusCode, err) } response.Body.Close() metricsResponse, err := http.Get(server.URL + "/metrics") if err != nil { t.Fatal(err) } defer metricsResponse.Body.Close() exported, err := io.ReadAll(metricsResponse.Body) if err != nil { t.Fatal(err) } if !strings.Contains(string(exported), `cosmic_clash_api_server_conflicts_total{kind="register"} 1`) { t.Fatalf("register conflict was not exported: %s", exported) } } func TestServerShutdownAPIRequiresBoundWorkloadAndDelegatesAcknowledgement(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} shutdowner := &serverShutdownerSpy{} service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, at time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" || !at.Equal(now) { t.Fatalf("verifier input=%q %v", token, at) } return binding, nil }, ServerShutdowner: shutdowner} server := httptest.NewServer(service.Handler()) defer server.Close() req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/shutdown", strings.NewReader(`{"reason":"server_draining"}`)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "shutdown-key-123456") response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusNoContent { t.Fatalf("status=%v err=%v", response.StatusCode, err) } response.Body.Close() if shutdowner.calls != 1 || shutdowner.binding != binding || shutdowner.reason != "server_draining" || shutdowner.key != "shutdown-key-123456" { t.Fatalf("shutdown=%+v", shutdowner) } req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/shutdown", strings.NewReader(`{"reason":"bad\nreason"}`)) req.Header.Set("Authorization", "Bearer workload-token") req.Header.Set("Idempotency-Key", "shutdown-key-123456") response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusUnprocessableEntity || shutdowner.calls != 1 { t.Fatalf("invalid shutdown status=%v err=%v calls=%d", response.StatusCode, err, shutdowner.calls) } response.Body.Close() } func TestServerConnectionAPIRequiresBoundWorkloadAndOpaqueAssignedPlayer(t *testing.T) { now := time.Unix(1000, 0).UTC() binding := domain.WorkloadBinding{AllocationID: "allocation-123456", MatchID: "match-1234567890", ServerID: "server-123456789"} recorder := &serverConnectionSpy{} service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, _ time.Time) (domain.WorkloadBinding, error) { if token != "workload-token" { return domain.WorkloadBinding{}, errors.New("bad token") } return binding, nil }, ServerConnections: recorder} server := httptest.NewServer(service.Handler()) defer server.Close() request := func(operation, serverID, playerID, token, key, bodySuffix string) (int, string) { body := fmt.Sprintf(`{"player_id":%q%s}`, playerID, bodySuffix) req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/"+serverID+"/"+operation, strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Idempotency-Key", key) response, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } responseBody, _ := io.ReadAll(response.Body) response.Body.Close() return response.StatusCode, string(responseBody) } if got, body := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789", `,"expected_generation":0`); got != http.StatusOK || !strings.Contains(body, `"generation":1`) { t.Fatalf("connection status = %d", got) } if recorder.connectCalls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || recorder.expectedGeneration != 0 || recorder.key != "connect-player-123456789" { t.Fatalf("connection receipt = %+v", recorder) } if got, body := request("connect", binding.ServerID, "player-legacy-123456", "workload-token", "connect-legacy-123456", ""); got != http.StatusNoContent || body != "" { t.Fatalf("legacy connection status=%d body=%q", got, body) } if got, _ := request("connect", "server-000000000", "player-123456789", "workload-token", "connect-player-123456789", ""); got != http.StatusUnauthorized { t.Fatalf("wrong server status = %d", got) } if got, _ := request("connect", binding.ServerID, "short", "workload-token", "connect-player-short-123", ""); got != http.StatusUnprocessableEntity { t.Fatalf("short player status = %d", got) } if recorder.connectCalls != 2 { t.Fatalf("invalid receipts reached backend: %d", recorder.connectCalls) } if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-player-123456789", `,"generation":1`); got != http.StatusNoContent { t.Fatalf("disconnect status = %d", got) } if recorder.disconnectCalls != 1 || recorder.generation != 1 { t.Fatalf("disconnect receipt = %+v", recorder) } if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-zero-123456", ""); got != http.StatusUnprocessableEntity || recorder.disconnectCalls != 1 { t.Fatalf("zero-generation disconnect status=%d calls=%d", got, recorder.disconnectCalls) } recorder.err = errors.New("database unavailable") if got, _ := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123", `,"expected_generation":1`); got != http.StatusServiceUnavailable { t.Fatalf("recorder outage status = %d, want retryable 503", got) } } func TestMetricsEndpointExportsBoundedAPILatencyAndSkipsItsOwnScrape(t *testing.T) { metrics := observability.NewMetrics() service := &Service{Metrics: metrics, Now: time.Now} server := httptest.NewServer(service.Handler()) defer server.Close() response, err := http.Get(server.URL + "/healthz") if err != nil { t.Fatal(err) } response.Body.Close() response, err = http.Get(server.URL + "/unknown/secret-token") if err != nil { t.Fatal(err) } response.Body.Close() response, err = http.Get(server.URL + "/metrics") if err != nil { t.Fatal(err) } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { t.Fatal(err) } if response.StatusCode != http.StatusOK || !strings.Contains(string(body), `operation="other",status="4xx"`) || strings.Contains(string(body), "secret-token") { t.Fatalf("metrics status=%d body=%s", response.StatusCode, body) } } func TestControlPlaneLivenessAndDatastoreReadinessAreIndependent(t *testing.T) { ready := false checks := 0 limiter, err := NewRateLimiter(1, time.Minute, 8) if err != nil { t.Fatal(err) } service := &Service{ RateLimiter: limiter, Now: func() time.Time { return time.Unix(1000, 0) }, ReadinessCheck: func(context.Context) error { checks++ if !ready { return errors.New("database unavailable") } return nil }, } handler := service.Handler() status := func(method, path string) int { recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, httptest.NewRequest(method, path, nil)) return recorder.Code } if got := status(http.MethodGet, "/healthz"); got != http.StatusOK { t.Fatalf("liveness during datastore outage = %d", got) } if got := status(http.MethodGet, "/readyz"); got != http.StatusServiceUnavailable { t.Fatalf("readiness during datastore outage = %d", got) } ready = true if got := status(http.MethodGet, "/readyz"); got != http.StatusOK { t.Fatalf("recovered readiness = %d", got) } if got := status(http.MethodGet, "/healthz"); got != http.StatusOK { t.Fatalf("repeated probe was incorrectly rate limited: %d", got) } if checks != 2 { t.Fatalf("readiness checks = %d", checks) } if got := status(http.MethodPost, "/readyz"); got != http.StatusMethodNotAllowed { t.Fatalf("readiness mutation status = %d", got) } } func TestControlPlaneReadinessFailsClosedWithoutCheck(t *testing.T) { recorder := httptest.NewRecorder() (&Service{}).Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if recorder.Code != http.StatusServiceUnavailable { t.Fatalf("unconfigured readiness status = %d", recorder.Code) } } 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 TestProbeAPIRecordsOnlyValidatedServerEvidence(t *testing.T) { now := time.Unix(1000, 0).UTC() sessions := domain.NewSessionStore() session, token, _ := sessions.Issue("player-a", time.Hour, now) recorder := &probeRecorderSpy{} service := &Service{Sessions: sessions, Now: func() time.Time { return now }, ProbeRecorder: recorder, Probe: func(_ string, region string, location, nonce []byte, _ time.Time) (domain.ProbeEvidence, []byte, error) { return domain.ProbeEvidence{OpaqueLocation: location, Nonce: nonce, IssuedAt: now, Region: region, ServerRTT: 37 * time.Millisecond}, nonce, nil }} server := httptest.NewServer(service.Handler()) defer server.Close() req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/probes/NA", strings.NewReader(`{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U="}`)) req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) response, err := http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusAccepted { t.Fatalf("status=%v err=%v", response.StatusCode, err) } response.Body.Close() if recorder.calls != 1 || recorder.last.player != "player-a" || recorder.last.region != "NA" || recorder.last.rtt != 37*time.Millisecond { t.Fatalf("recorded probe=%+v calls=%d", recorder.last, recorder.calls) } recorder.err = errors.New("database unavailable") req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/probes/NA", strings.NewReader(`{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U="}`)) req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) response, err = http.DefaultClient.Do(req) if err != nil || response.StatusCode != http.StatusServiceUnavailable { t.Fatalf("persistence 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", Endpoint: "127.0.0.1:30001", 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) } } func TestAssignmentEventUsesAuthoritativeRevisionWithoutChangingResponseShape(t *testing.T) { now := time.Unix(1000, 0).UTC() view := AssignmentView{MatchID: "match-1", ServerID: "server-1", PlayerID: "player-a", Revision: 7} event := assignmentChangedEvent(view, now) if event.Revision != 7 || event.ResourceID != "match-1" || event.PlayerID != "player-a" { t.Fatalf("assignment event = %+v", event) } payload, err := json.Marshal(view) if err != nil { t.Fatal(err) } if strings.Contains(string(payload), "revision") { t.Fatalf("assignment response leaked event revision: %s", payload) } } func TestAssignmentEndpointValidationRejectsAmbiguousOrUnsafeEndpoints(t *testing.T) { for _, endpoint := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:70000", "https://127.0.0.1:1", "127.0.0.1:1/path"} { if validAssignmentEndpoint(endpoint) { t.Fatalf("unsafe endpoint accepted: %q", endpoint) } } for _, endpoint := range []string{"127.0.0.1:1", "example.invalid:65535", "[2001:db8::1]:31001"} { if !validAssignmentEndpoint(endpoint) { t.Fatalf("valid endpoint rejected: %q", endpoint) } } }