b3f24cc440
Item 7 of the spec wants game-state and membership-state changes to land as durable inbox entries the affected players can re-read after the fact — push alone times out of the 5-minute ring buffer. Stage B adds the admin-kind send matrix (owner-driven via /user, site-admin driven via /admin) plus the lobby lifecycle hooks: paused / cancelled emit a broadcast system mail to active members, kick / ban emit a single-recipient system mail to the affected user (which they keep read access to even after the membership row is revoked, per item 8). Migration relaxes diplomail_messages_kind_sender_chk so an owner sending kind=admin keeps sender_kind=player; the new LifecyclePublisher dep on lobby.Service is wired through a thin adapter in cmd/backend/main, mirroring how lobby's notification publisher is plumbed today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
474 lines
13 KiB
Go
474 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"galaxy/backend/internal/server/middleware/basicauth"
|
|
|
|
"github.com/getkin/kin-openapi/openapi3"
|
|
"github.com/getkin/kin-openapi/openapi3filter"
|
|
"github.com/getkin/kin-openapi/routers"
|
|
"github.com/getkin/kin-openapi/routers/gorillamux"
|
|
)
|
|
|
|
// contractStubAdminPassword is the password the contract test injects
|
|
// into the stub Basic Auth verifier wired by NewRouter. The value
|
|
// never leaves the test binary; production wires the verifier from
|
|
// the Postgres-backed admin.Service.
|
|
const contractStubAdminPassword = "contract-test-secret"
|
|
|
|
// stubUserID is the deterministic UUID injected into `X-User-ID` for the
|
|
// authenticated user surface.
|
|
const stubUserID = "00000000-0000-0000-0000-0000000000a1"
|
|
|
|
// pathParamStubs lists the deterministic substitutions used to fill the path
|
|
// templates declared in `openapi.yaml`. Every parameter that appears in a path
|
|
// must have an entry here; the test fails loudly if a new parameter is added
|
|
// to the spec without updating this map.
|
|
var pathParamStubs = map[string]string{
|
|
"game_id": "00000000-0000-0000-0000-000000000001",
|
|
"application_id": "00000000-0000-0000-0000-000000000002",
|
|
"invite_id": "00000000-0000-0000-0000-000000000003",
|
|
"membership_id": "00000000-0000-0000-0000-000000000004",
|
|
"notification_id": "00000000-0000-0000-0000-000000000005",
|
|
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
|
"user_id": "00000000-0000-0000-0000-000000000007",
|
|
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
|
"battle_id": "00000000-0000-0000-0000-000000000009",
|
|
"message_id": "00000000-0000-0000-0000-00000000000a",
|
|
"id": "1.2.3",
|
|
"username": "alice",
|
|
"turn": "42",
|
|
}
|
|
|
|
// queryParamStubs lists the deterministic substitutions used to fill
|
|
// query-string parameters declared in `openapi.yaml`. Every required
|
|
// query parameter must have an entry here; optional ones can stay
|
|
// blank (the contract test omits them when no stub is registered).
|
|
var queryParamStubs = map[string]string{
|
|
"turn": "42",
|
|
}
|
|
|
|
// requestBodyStubs lists the JSON request bodies the contract test sends for
|
|
// each operationId. Operations missing from the map default to an empty
|
|
// object `{}`, which is a valid placeholder thanks to `additionalProperties:
|
|
// true` in the matching schemas. Operations that require specific fields
|
|
// listed in `openapi.yaml` get a hand-crafted body here.
|
|
var requestBodyStubs = map[string]map[string]any{
|
|
"publicAuthSendEmailCode": {
|
|
"email": "pilot@example.test",
|
|
},
|
|
"publicAuthConfirmEmailCode": {
|
|
"challenge_id": "challenge-123",
|
|
"code": "654321",
|
|
"client_public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
"time_zone": "Europe/Kaliningrad",
|
|
},
|
|
"adminAdminAccountsCreate": {
|
|
"username": "carol",
|
|
"password": "carol-secret",
|
|
},
|
|
"adminAdminAccountsResetPassword": {
|
|
"password": "carol-new-secret",
|
|
},
|
|
"adminEngineVersionsCreate": {
|
|
"version": "1.2.3",
|
|
"image_ref": "registry.test/galaxy/engine:1.2.3",
|
|
},
|
|
"adminRuntimesPatch": {
|
|
"target_version": "1.2.4",
|
|
},
|
|
"userLobbyRaceNamesRegister": {
|
|
"name": "AndromedaConfederacy",
|
|
},
|
|
"adminUsersAddSanction": {
|
|
"sanction_code": "permanent_block",
|
|
"scope": "platform",
|
|
"reason_code": "tos_violation",
|
|
"actor": map[string]any{
|
|
"type": "admin",
|
|
"id": "operator",
|
|
},
|
|
},
|
|
"adminUsersAddLimit": {
|
|
"limit_code": "max_active_games",
|
|
"value": 3,
|
|
"reason_code": "manual_review",
|
|
"actor": map[string]any{
|
|
"type": "admin",
|
|
"id": "operator",
|
|
},
|
|
},
|
|
"adminUsersAddEntitlement": {
|
|
"tier": "monthly",
|
|
"source": "admin",
|
|
"actor": map[string]any{
|
|
"type": "admin",
|
|
"id": "operator",
|
|
},
|
|
},
|
|
"userLobbyGamesCreate": {
|
|
"game_name": "Contract Test Game",
|
|
"visibility": "private",
|
|
"min_players": 2,
|
|
"max_players": 8,
|
|
"start_gap_hours": 24,
|
|
"start_gap_players": 2,
|
|
"enrollment_ends_at": "2099-01-02T03:04:05Z",
|
|
"turn_schedule": "0 0 * * *",
|
|
"target_engine_version": "1.0.0",
|
|
},
|
|
"adminGamesCreate": {
|
|
"game_name": "Contract Test Public Game",
|
|
"min_players": 4,
|
|
"max_players": 12,
|
|
"start_gap_hours": 12,
|
|
"start_gap_players": 4,
|
|
"enrollment_ends_at": "2099-01-02T03:04:05Z",
|
|
"turn_schedule": "0 6 * * *",
|
|
"target_engine_version": "1.0.0",
|
|
},
|
|
"userLobbyApplicationsSubmit": {
|
|
"race_name": "ContractTestRace",
|
|
},
|
|
"userLobbyInvitesIssue": {
|
|
"invited_user_id": pathParamStubs["user_id"],
|
|
"race_name": "ContractTestRace",
|
|
},
|
|
"adminGamesBanMember": {
|
|
"user_id": pathParamStubs["user_id"],
|
|
"reason": "ToS violation",
|
|
},
|
|
"userMailSendPersonal": {
|
|
"recipient_user_id": pathParamStubs["user_id"],
|
|
"subject": "Contract test subject",
|
|
"body": "Contract test body",
|
|
},
|
|
"userMailSendAdmin": {
|
|
"target": "user",
|
|
"recipient_user_id": pathParamStubs["user_id"],
|
|
"subject": "Contract test admin subject",
|
|
"body": "Contract test admin body",
|
|
},
|
|
"adminDiplomailSend": {
|
|
"target": "user",
|
|
"recipient_user_id": pathParamStubs["user_id"],
|
|
"subject": "Contract test admin subject",
|
|
"body": "Contract test admin body",
|
|
},
|
|
}
|
|
|
|
// TestOpenAPIContract is the top-level OpenAPI contract test. It
|
|
// validates that:
|
|
//
|
|
// 1. backend/openapi.yaml is well-formed;
|
|
// 2. every documented operation maps to a registered gin handler;
|
|
// 3. each handler returns a representable HTTP response that satisfies
|
|
// the declared response schema (any defensive `501 not_implemented`
|
|
// placeholder also conforms).
|
|
func TestOpenAPIContract(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc, specPath := loadOpenAPISpec(t)
|
|
spec := mustValidateSpec(t, doc, specPath)
|
|
|
|
router := mustNewGorillamuxRouter(t, doc)
|
|
engine := mustBuildEngine(t)
|
|
|
|
expectedOps := collectOperations(t, doc)
|
|
if len(expectedOps) == 0 {
|
|
t.Fatalf("openapi.yaml declares no operations")
|
|
}
|
|
|
|
_ = spec
|
|
processed := 0
|
|
for _, op := range expectedOps {
|
|
t.Run(op.method+" "+op.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
runContractCase(t, router, engine, op)
|
|
})
|
|
processed++
|
|
}
|
|
|
|
t.Logf("contract test exercised %d operations from %s", processed, specPath)
|
|
}
|
|
|
|
type contractOperation struct {
|
|
method string
|
|
path string
|
|
operationID string
|
|
op *openapi3.Operation
|
|
}
|
|
|
|
func collectOperations(t *testing.T, doc *openapi3.T) []contractOperation {
|
|
t.Helper()
|
|
|
|
var ops []contractOperation
|
|
for path, item := range doc.Paths.Map() {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
for method, op := range item.Operations() {
|
|
if op == nil {
|
|
continue
|
|
}
|
|
ops = append(ops, contractOperation{
|
|
method: method,
|
|
path: path,
|
|
operationID: op.OperationID,
|
|
op: op,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(ops, func(i, j int) bool {
|
|
if ops[i].path == ops[j].path {
|
|
return ops[i].method < ops[j].method
|
|
}
|
|
return ops[i].path < ops[j].path
|
|
})
|
|
return ops
|
|
}
|
|
|
|
func loadOpenAPISpec(t *testing.T) (*openapi3.T, string) {
|
|
t.Helper()
|
|
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatalf("runtime.Caller(0) failed")
|
|
}
|
|
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "openapi.yaml")
|
|
|
|
loader := openapi3.NewLoader()
|
|
loader.IsExternalRefsAllowed = false
|
|
doc, err := loader.LoadFromFile(specPath)
|
|
if err != nil {
|
|
t.Fatalf("load openapi spec %s: %v", specPath, err)
|
|
}
|
|
return doc, specPath
|
|
}
|
|
|
|
func mustValidateSpec(t *testing.T, doc *openapi3.T, specPath string) *openapi3.T {
|
|
t.Helper()
|
|
if err := doc.Validate(context.Background()); err != nil {
|
|
t.Fatalf("openapi spec %s did not validate: %v", specPath, err)
|
|
}
|
|
if doc.Info == nil || doc.Info.Version != "v1" {
|
|
t.Fatalf("openapi spec must declare info.version v1, got %+v", doc.Info)
|
|
}
|
|
return doc
|
|
}
|
|
|
|
func mustNewGorillamuxRouter(t *testing.T, doc *openapi3.T) routers.Router {
|
|
t.Helper()
|
|
router, err := gorillamux.NewRouter(doc)
|
|
if err != nil {
|
|
t.Fatalf("build gorillamux router: %v", err)
|
|
}
|
|
return router
|
|
}
|
|
|
|
func mustBuildEngine(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
verifier := basicauth.NewStaticVerifier(contractStubAdminPassword)
|
|
handler, err := NewRouter(RouterDependencies{
|
|
AdminVerifier: verifier,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("build router: %v", err)
|
|
}
|
|
return handler
|
|
}
|
|
|
|
func runContractCase(t *testing.T, router routers.Router, engine http.Handler, c contractOperation) {
|
|
t.Helper()
|
|
if c.operationID == "" {
|
|
t.Fatalf("operation %s %s has no operationId", c.method, c.path)
|
|
}
|
|
|
|
req := buildRequest(t, c)
|
|
|
|
route, pathParams, err := router.FindRoute(req)
|
|
if err != nil {
|
|
t.Fatalf("find route for %s %s: %v", c.method, req.URL.Path, err)
|
|
}
|
|
|
|
requestInput := &openapi3filter.RequestValidationInput{
|
|
Request: req,
|
|
PathParams: pathParams,
|
|
Route: route,
|
|
Options: &openapi3filter.Options{
|
|
AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
|
|
},
|
|
}
|
|
if err := openapi3filter.ValidateRequest(req.Context(), requestInput); err != nil {
|
|
t.Fatalf("ValidateRequest %s %s (%s): %v", c.method, c.path, c.operationID, err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, req)
|
|
|
|
expectPlaceholder := groupForPath(c.path) != "probe"
|
|
if expectPlaceholder {
|
|
if got, want := recorder.Code, http.StatusNotImplemented; got != want {
|
|
t.Fatalf("operation %s %s (%s) returned status %d, want %d (body: %s)",
|
|
c.method, c.path, c.operationID, got, want, recorder.Body.String())
|
|
}
|
|
} else if recorder.Code/100 != 2 {
|
|
t.Fatalf("probe operation %s %s (%s) returned non-2xx status %d (body: %s)",
|
|
c.method, c.path, c.operationID, recorder.Code, recorder.Body.String())
|
|
}
|
|
if ct := recorder.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Fatalf("operation %s %s (%s) returned Content-Type %q, want application/json", c.method, c.path, c.operationID, ct)
|
|
}
|
|
|
|
responseInput := &openapi3filter.ResponseValidationInput{
|
|
RequestValidationInput: requestInput,
|
|
Status: recorder.Code,
|
|
Header: recorder.Header(),
|
|
Options: &openapi3filter.Options{
|
|
IncludeResponseStatus: true,
|
|
},
|
|
}
|
|
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
|
if err := openapi3filter.ValidateResponse(req.Context(), responseInput); err != nil {
|
|
t.Fatalf("ValidateResponse %s %s (%s): %v", c.method, c.path, c.operationID, err)
|
|
}
|
|
}
|
|
|
|
func buildRequest(t *testing.T, c contractOperation) *http.Request {
|
|
t.Helper()
|
|
|
|
target := substitutePathParams(t, c.path)
|
|
if query := buildQuery(t, c); query != "" {
|
|
target += "?" + query
|
|
}
|
|
url := "http://backend.internal" + target
|
|
|
|
body := bodyFor(t, c)
|
|
|
|
req, err := http.NewRequest(c.method, url, body.reader)
|
|
if err != nil {
|
|
t.Fatalf("construct request %s %s: %v", c.method, url, err)
|
|
}
|
|
if body.contentType != "" {
|
|
req.Header.Set("Content-Type", body.contentType)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
switch groupForPath(c.path) {
|
|
case "user":
|
|
req.Header.Set("X-User-ID", stubUserID)
|
|
case "admin":
|
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("operator:"+contractStubAdminPassword)))
|
|
}
|
|
req = req.WithContext(context.Background())
|
|
return req
|
|
}
|
|
|
|
type requestBody struct {
|
|
reader io.Reader
|
|
contentType string
|
|
}
|
|
|
|
func bodyFor(t *testing.T, c contractOperation) requestBody {
|
|
t.Helper()
|
|
|
|
if c.op.RequestBody == nil || c.op.RequestBody.Value == nil {
|
|
return requestBody{}
|
|
}
|
|
media := c.op.RequestBody.Value.Content.Get("application/json")
|
|
if media == nil {
|
|
return requestBody{}
|
|
}
|
|
|
|
stub, ok := requestBodyStubs[c.operationID]
|
|
if !ok {
|
|
stub = map[string]any{}
|
|
}
|
|
encoded, err := json.Marshal(stub)
|
|
if err != nil {
|
|
t.Fatalf("marshal request body for %s: %v", c.operationID, err)
|
|
}
|
|
return requestBody{
|
|
reader: bytes.NewReader(encoded),
|
|
contentType: "application/json",
|
|
}
|
|
}
|
|
|
|
func buildQuery(t *testing.T, c contractOperation) string {
|
|
t.Helper()
|
|
if c.op == nil {
|
|
return ""
|
|
}
|
|
values := make([]string, 0, len(c.op.Parameters))
|
|
for _, p := range c.op.Parameters {
|
|
if p == nil || p.Value == nil {
|
|
continue
|
|
}
|
|
if p.Value.In != "query" {
|
|
continue
|
|
}
|
|
stub, ok := queryParamStubs[p.Value.Name]
|
|
if !ok {
|
|
if p.Value.Required {
|
|
t.Fatalf("operation %q requires query parameter %q with no stub registered", c.operationID, p.Value.Name)
|
|
}
|
|
continue
|
|
}
|
|
values = append(values, p.Value.Name+"="+stub)
|
|
}
|
|
return strings.Join(values, "&")
|
|
}
|
|
|
|
func substitutePathParams(t *testing.T, templated string) string {
|
|
t.Helper()
|
|
|
|
result := templated
|
|
for {
|
|
open := strings.Index(result, "{")
|
|
if open < 0 {
|
|
break
|
|
}
|
|
close := strings.Index(result[open:], "}")
|
|
if close < 0 {
|
|
t.Fatalf("malformed path template %q", templated)
|
|
}
|
|
name := result[open+1 : open+close]
|
|
value, ok := pathParamStubs[name]
|
|
if !ok {
|
|
t.Fatalf("path template %q references unknown parameter %q", templated, name)
|
|
}
|
|
result = result[:open] + value + result[open+close+1:]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// groupForPath returns the route family that contractOperation.path belongs
|
|
// to. The classification drives test-side header injection (X-User-ID,
|
|
// Authorization).
|
|
func groupForPath(path string) string {
|
|
switch {
|
|
case strings.HasPrefix(path, "/api/v1/public"):
|
|
return "public"
|
|
case strings.HasPrefix(path, "/api/v1/user"):
|
|
return "user"
|
|
case strings.HasPrefix(path, "/api/v1/admin"):
|
|
return "admin"
|
|
case strings.HasPrefix(path, "/api/v1/internal"):
|
|
return "internal"
|
|
default:
|
|
return "probe"
|
|
}
|
|
}
|