mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(multiplayer): add server process/assignment-ready registration API
Add POST /v1/servers/{id}/register (and its /api/v1 contract alias),
authenticated by the same workload binding as the result route. A
game server reports its protocol version and image digest and asks
to advance ALLOCATING -> PROCESS_READY -> ASSIGNMENT_READY; the store
boundary (AdvanceServerRegistration) does this as one idempotent
SERIALIZABLE transaction that also advances every participant's queue
ticket, and gates the final transition on every participant having a
live, unexpired assignment.
Adversarial review of the surrounding routing turned up a pre-existing
bug: contractServerMutation rejected any path containing '/', so the
already-documented /api/v1/servers/{id}/result route (and this new
/register route) 404'd for every real caller despite being declared
in the OpenAPI contract. Fix it to delegate shape validation to
serverMutation, matching how contractQueueMutation handles its own
two-segment paths, and add a regression test covering both contract
routes end to end.
This commit is contained in:
@@ -41,6 +41,20 @@ type resultSubmitterSpy struct {
|
||||
result domain.MatchResult
|
||||
}
|
||||
|
||||
type serverRegistrarSpy struct {
|
||||
calls int
|
||||
binding domain.WorkloadBinding
|
||||
protocol int
|
||||
assignmentReady bool
|
||||
err error
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1018,6 +1032,73 @@ func TestServerResultAPIRequiresBoundWorkloadAndDelegatesDurableSubmission(t *te
|
||||
response.Body.Close()
|
||||
}
|
||||
|
||||
func TestContractServerRoutesAdaptTwoSegmentPaths(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
binding := domain.WorkloadBinding{AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"}
|
||||
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-1","protocol_version":1,"image_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assignment_ready":false}`
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/servers/server-1/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-1","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-1/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()
|
||||
}
|
||||
|
||||
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 TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
|
||||
Reference in New Issue
Block a user