mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 19:23:42 +00:00
feat: validate queue compatibility metadata
This commit is contained in:
+27
-5
@@ -19,12 +19,14 @@ import (
|
||||
const maxBodyBytes = 8 << 10
|
||||
|
||||
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
|
||||
type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error)
|
||||
type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error)
|
||||
|
||||
type Service struct {
|
||||
Sessions *domain.SessionStore
|
||||
Queue *domain.Queue
|
||||
Candidate CandidateProvider
|
||||
CandidateV2 CandidateProviderV2
|
||||
Probe ProbeProvider
|
||||
Now func() time.Time
|
||||
Proposals map[string]*domain.Proposal
|
||||
@@ -49,7 +51,10 @@ func (s *Service) health(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
type queueCreateRequest struct {
|
||||
TicketID string `json:"ticket_id"`
|
||||
TicketID string `json:"ticket_id"`
|
||||
Playlist string `json:"playlist"`
|
||||
ClientBuild string `json:"client_build"`
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
}
|
||||
type queueResponse struct {
|
||||
TicketID string `json:"ticket_id"`
|
||||
@@ -58,6 +63,7 @@ type queueResponse struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
EnqueuedAt time.Time `json:"enqueued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Playlist string `json:"playlist"`
|
||||
}
|
||||
|
||||
func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -69,7 +75,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s.Queue == nil || s.Candidate == nil {
|
||||
if s.Queue == nil || (s.Candidate == nil && s.CandidateV2 == nil) {
|
||||
writeError(w, http.StatusServiceUnavailable, "queue_unavailable")
|
||||
return
|
||||
}
|
||||
@@ -77,7 +83,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if input.TicketID == "" {
|
||||
if input.TicketID == "" || (input.Playlist != string(domain.Casual) && input.Playlist != string(domain.Ranked)) || input.ClientBuild == "" || len(input.ClientBuild) > 128 || input.ProtocolVersion < 1 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request")
|
||||
return
|
||||
}
|
||||
@@ -87,11 +93,27 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
candidate, err := s.Candidate(playerID, input.TicketID)
|
||||
spec := domain.QueueSpec{Playlist: domain.Playlist(input.Playlist), ClientBuild: input.ClientBuild, ProtocolVersion: input.ProtocolVersion}
|
||||
var candidate domain.Candidate
|
||||
var err error
|
||||
if s.CandidateV2 != nil {
|
||||
candidate, err = s.CandidateV2(playerID, input.TicketID, spec)
|
||||
} else {
|
||||
candidate, err = s.Candidate(playerID, input.TicketID)
|
||||
// Legacy providers predate queue compatibility metadata. The API has
|
||||
// validated the request; keep the resulting projection self-describing.
|
||||
candidate.Playlist = spec.Playlist
|
||||
candidate.ClientBuild = spec.ClientBuild
|
||||
candidate.ProtocolVersion = spec.ProtocolVersion
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, "candidate_unavailable")
|
||||
return
|
||||
}
|
||||
if candidate.PlayerID != playerID || candidate.TicketID != input.TicketID || candidate.Playlist != spec.Playlist || candidate.ClientBuild != spec.ClientBuild || candidate.ProtocolVersion != spec.ProtocolVersion {
|
||||
writeError(w, http.StatusUnprocessableEntity, "candidate_mismatch")
|
||||
return
|
||||
}
|
||||
ticket, err := s.Queue.Create(playerID, input.TicketID, key, candidate, now)
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
@@ -322,7 +344,7 @@ func decodeBody(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
}
|
||||
|
||||
func toQueueResponse(ticket domain.QueueTicket) queueResponse {
|
||||
return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt}
|
||||
return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, Playlist: string(ticket.Playlist), State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt}
|
||||
}
|
||||
|
||||
func toProposalResponse(proposal domain.Proposal) proposalResponse {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestAuthenticatedQueueAPIUsesServerCandidateAndRevisionedMutations(t *testi
|
||||
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"}`, headers)
|
||||
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)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ 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","player_id":"attacker"}`))
|
||||
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 {
|
||||
@@ -77,7 +77,7 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
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","unknown":true}`))
|
||||
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)
|
||||
@@ -99,7 +99,7 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
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"}{"ticket_id":"ticket-2"}`))
|
||||
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)
|
||||
@@ -112,6 +112,78 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
_ = 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 TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
@@ -129,7 +201,7 @@ func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testi
|
||||
}}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
create, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-recovery-123456"}`))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user