mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat: add participant-scoped proposal recovery
This commit is contained in:
+17
-1
@@ -277,7 +277,7 @@ type proposalResponse struct {
|
||||
}
|
||||
|
||||
func (s *Service) proposalMutation(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
|
||||
}
|
||||
@@ -286,6 +286,22 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/proposals/"), "/")
|
||||
if r.Method == http.MethodGet {
|
||||
if len(parts) != 1 || parts[0] == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
s.proposalMu.Lock()
|
||||
defer s.proposalMu.Unlock()
|
||||
proposal, exists := s.Proposals[parts[0]]
|
||||
if !exists || proposal == nil || !proposal.HasParticipant(playerID) {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
proposal.Expire(s.now())
|
||||
writeJSON(w, http.StatusOK, toProposalResponse(*proposal))
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || parts[0] == "" || (parts[1] != "accept" && parts[1] != "decline") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
|
||||
@@ -505,3 +505,53 @@ func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
|
||||
}
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,13 @@ func (p *Proposal) participantIndex(playerID string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// HasParticipant is the read-side authorization check for proposal recovery.
|
||||
// A proposal contains private matchmaking state, so non-participants must not
|
||||
// be able to enumerate or observe it through the control plane.
|
||||
func (p *Proposal) HasParticipant(playerID string) bool {
|
||||
return p != nil && playerID != "" && p.participantIndex(playerID) >= 0
|
||||
}
|
||||
|
||||
func (p *Proposal) allAccepted() bool {
|
||||
for _, participant := range p.Participants {
|
||||
if participant.Response != AcceptedResponse {
|
||||
|
||||
Reference in New Issue
Block a user