feat: backend service
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
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",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user