mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:13:42 +00:00
feat: add authenticated queue recovery
This commit is contained in:
+14
-1
@@ -98,7 +98,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
@@ -111,6 +111,19 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/queue/"), "/")
|
||||
if r.Method == http.MethodGet {
|
||||
if len(parts) != 1 || parts[0] == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
ticket, err := s.Queue.Get(playerID, parts[0], s.now())
|
||||
if err != nil {
|
||||
writeDomainError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toQueueResponse(ticket))
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || parts[0] == "" || (parts[1] != "heartbeat" && parts[1] != "cancel") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
|
||||
@@ -112,6 +112,53 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
_ = 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()
|
||||
|
||||
@@ -139,6 +139,23 @@ func (q *Queue) Cancel(playerID, ticketID, idempotencyKey string, expectedRevisi
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// Get is the recovery read used after a client restart or missed event. It
|
||||
// never returns another player's ticket and expires stale queue presence before
|
||||
// deciding what the caller may resume.
|
||||
func (q *Queue) Get(playerID, ticketID string, now time.Time) (QueueTicket, error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.expireLocked(now)
|
||||
ticket, err := q.ownedTicket(playerID, ticketID)
|
||||
if err != nil {
|
||||
return QueueTicket{}, err
|
||||
}
|
||||
if ticket.State == Expired {
|
||||
return QueueTicket{}, ErrTicketExpired
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func (q *Queue) Expire(now time.Time) []QueueTicket {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user