mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
239 lines
10 KiB
Go
239 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
)
|
|
|
|
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"}`, 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 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.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","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"}{"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 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"}`))
|
|
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)
|
|
}
|
|
}
|