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", }, } // 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" } }