package rtmanager import ( "context" "net/http" "path/filepath" "runtime" "testing" "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/require" ) // TestInternalOpenAPISpecValidates loads internal-openapi.yaml and verifies // it is a syntactically valid OpenAPI 3.0 document. func TestInternalOpenAPISpecValidates(t *testing.T) { t.Parallel() loadInternalOpenAPISpec(t) } // TestInternalSpecFreezesOperationIDs verifies that every documented // endpoint declares the exact operationId required by the Runtime Manager // internal contract. Missing or renamed operationIds break the contract // for Game Master and Admin Service. func TestInternalSpecFreezesOperationIDs(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) cases := []struct { method string path string operationID string }{ {http.MethodGet, "/healthz", "internalHealthz"}, {http.MethodGet, "/readyz", "internalReadyz"}, {http.MethodGet, "/api/v1/internal/runtimes", "internalListRuntimes"}, {http.MethodGet, "/api/v1/internal/runtimes/{game_id}", "internalGetRuntime"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/start", "internalStartRuntime"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/stop", "internalStopRuntime"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/restart", "internalRestartRuntime"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/patch", "internalPatchRuntime"}, {http.MethodDelete, "/api/v1/internal/runtimes/{game_id}/container", "internalCleanupRuntimeContainer"}, } for _, tc := range cases { t.Run(tc.operationID, func(t *testing.T) { t.Parallel() op := getOperation(t, doc, tc.path, tc.method) require.Equal(t, tc.operationID, op.OperationID) }) } } // TestInternalSpecFreezesRuntimeRecordSchema verifies that RuntimeRecord // declares the required field set documented in // rtmanager/README.md §Persistence Layout, with the status enum frozen. func TestInternalSpecFreezesRuntimeRecordSchema(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "RuntimeRecord") assertRequiredFields(t, schema, "game_id", "status", "state_path", "docker_network", "last_op_at", "created_at", ) for _, optional := range []string{ "current_container_id", "current_image_ref", "engine_endpoint", "started_at", "stopped_at", "removed_at", } { require.Contains(t, schema.Value.Properties, optional, "RuntimeRecord.%s must be present in properties", optional) } assertStringEnum(t, schema, "status", "running", "stopped", "removed") } // TestInternalSpecFreezesStartRequest verifies that StartRequest requires // only image_ref and rejects unknown fields. func TestInternalSpecFreezesStartRequest(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "StartRequest") assertRequiredFields(t, schema, "image_ref") require.NotNil(t, schema.Value.AdditionalProperties.Has) require.False(t, *schema.Value.AdditionalProperties.Has, "StartRequest must reject unknown fields") } // TestInternalSpecFreezesStopRequest verifies that StopRequest requires // only reason, that reason references the StopReason schema, and that // unknown fields are rejected. func TestInternalSpecFreezesStopRequest(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "StopRequest") assertRequiredFields(t, schema, "reason") require.NotNil(t, schema.Value.AdditionalProperties.Has) require.False(t, *schema.Value.AdditionalProperties.Has, "StopRequest must reject unknown fields") reason := schema.Value.Properties["reason"] require.NotNil(t, reason, "StopRequest.reason must be present") require.Equal(t, "#/components/schemas/StopReason", reason.Ref, "StopRequest.reason must reference StopReason") } // TestInternalSpecFreezesPatchRequest verifies that PatchRequest requires // only image_ref and rejects unknown fields. func TestInternalSpecFreezesPatchRequest(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "PatchRequest") assertRequiredFields(t, schema, "image_ref") require.NotNil(t, schema.Value.AdditionalProperties.Has) require.False(t, *schema.Value.AdditionalProperties.Has, "PatchRequest must reject unknown fields") } // TestInternalSpecFreezesStopReasonEnum verifies that the stop reason enum // matches the contract recorded in // rtmanager/README.md §Async Stream Contracts. func TestInternalSpecFreezesStopReasonEnum(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "StopReason") got := make([]string, 0, len(schema.Value.Enum)) for _, value := range schema.Value.Enum { got = append(got, value.(string)) } require.ElementsMatch(t, []string{"orphan_cleanup", "cancelled", "finished", "admin_request", "timeout"}, got) } // TestInternalSpecFreezesErrorCodeCatalog verifies that ErrorCode contains // every stable code declared in rtmanager/README.md §Error Model. func TestInternalSpecFreezesErrorCodeCatalog(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "ErrorCode") got := make([]string, 0, len(schema.Value.Enum)) for _, value := range schema.Value.Enum { got = append(got, value.(string)) } require.ElementsMatch(t, []string{ "invalid_request", "not_found", "conflict", "service_unavailable", "internal_error", "image_pull_failed", "image_ref_not_semver", "semver_patch_only", "container_start_failed", "start_config_invalid", "docker_unavailable", "replay_no_op", }, got) } // TestInternalSpecFreezesErrorEnvelope verifies that ErrorResponse uses the // `{ "error": { "code", "message" } }` shape and that error.code references // the ErrorCode enum. func TestInternalSpecFreezesErrorEnvelope(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) envelope := componentSchemaRef(t, doc, "ErrorResponse") assertRequiredFields(t, envelope, "error") require.Equal(t, "#/components/schemas/ErrorBody", envelope.Value.Properties["error"].Ref, "ErrorResponse.error must reference ErrorBody") body := componentSchemaRef(t, doc, "ErrorBody") assertRequiredFields(t, body, "code", "message") require.Equal(t, "#/components/schemas/ErrorCode", body.Value.Properties["code"].Ref, "ErrorBody.code must reference ErrorCode") require.Equal(t, "string", body.Value.Properties["message"].Value.Type.Slice()[0], "ErrorBody.message must be a string") } // TestInternalSpecFreezesProbeResponses verifies that /healthz returns 200 // with the probe payload and /readyz declares both 200 and 503. func TestInternalSpecFreezesProbeResponses(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) healthz := getOperation(t, doc, "/healthz", http.MethodGet) assertSchemaRef(t, responseSchemaRef(t, healthz, http.StatusOK), "#/components/schemas/ProbeResponse", "internalHealthz 200") readyz := getOperation(t, doc, "/readyz", http.MethodGet) assertSchemaRef(t, responseSchemaRef(t, readyz, http.StatusOK), "#/components/schemas/ProbeResponse", "internalReadyz 200") require.NotNil(t, readyz.Responses.Status(http.StatusServiceUnavailable), "internalReadyz must declare a 503 response") } // TestInternalSpecFreezesXGalaxyCallerHeader verifies that the optional // X-Galaxy-Caller header parameter is declared and referenced from every // runtime operation. Removing the parameter or detaching it from any of // the seven runtime endpoints would silently drop the only signal RTM // uses to distinguish gm_rest from admin_rest in operation_log. func TestInternalSpecFreezesXGalaxyCallerHeader(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) param := doc.Components.Parameters["XGalaxyCallerHeader"] require.NotNil(t, param, "XGalaxyCallerHeader parameter must be declared") require.NotNil(t, param.Value, "XGalaxyCallerHeader parameter must have a value") require.Equal(t, "header", param.Value.In) require.Equal(t, "X-Galaxy-Caller", param.Value.Name) require.False(t, param.Value.Required, "X-Galaxy-Caller must be optional") enum := param.Value.Schema.Value.Enum got := make([]string, 0, len(enum)) for _, value := range enum { got = append(got, value.(string)) } require.ElementsMatch(t, []string{"gm", "admin"}, got) runtimeOps := []struct { method string path string }{ {http.MethodGet, "/api/v1/internal/runtimes"}, {http.MethodGet, "/api/v1/internal/runtimes/{game_id}"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/start"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/stop"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/restart"}, {http.MethodPost, "/api/v1/internal/runtimes/{game_id}/patch"}, {http.MethodDelete, "/api/v1/internal/runtimes/{game_id}/container"}, } for _, rop := range runtimeOps { t.Run(rop.method+" "+rop.path, func(t *testing.T) { t.Parallel() op := getOperation(t, doc, rop.path, rop.method) found := false for _, ref := range op.Parameters { if ref.Ref == "#/components/parameters/XGalaxyCallerHeader" { found = true break } } require.Truef(t, found, "%s %s must reference XGalaxyCallerHeader", rop.method, rop.path) }) } } // TestInternalSpecFreezesRuntimesListShape verifies that the list endpoint // returns the items envelope expected by callers. func TestInternalSpecFreezesRuntimesListShape(t *testing.T) { t.Parallel() doc := loadInternalOpenAPISpec(t) schema := componentSchemaRef(t, doc, "RuntimesList") assertRequiredFields(t, schema, "items") items := schema.Value.Properties["items"] require.NotNil(t, items, "RuntimesList.items must be declared") require.Equal(t, "#/components/schemas/RuntimeRecord", items.Value.Items.Ref, "RuntimesList.items[] must reference RuntimeRecord") } func loadInternalOpenAPISpec(t *testing.T) *openapi3.T { t.Helper() _, thisFile, _, ok := runtime.Caller(0) if !ok { require.FailNow(t, "runtime.Caller failed") } specPath := filepath.Join(filepath.Dir(thisFile), "api", "internal-openapi.yaml") loader := openapi3.NewLoader() doc, err := loader.LoadFromFile(specPath) if err != nil { require.Failf(t, "test failed", "load spec %s: %v", specPath, err) } if doc == nil { require.Failf(t, "test failed", "load spec %s: returned nil document", specPath) } if err := doc.Validate(context.Background()); err != nil { require.Failf(t, "test failed", "validate spec %s: %v", specPath, err) } return doc } func getOperation(t *testing.T, doc *openapi3.T, path, method string) *openapi3.Operation { t.Helper() if doc.Paths == nil { require.FailNow(t, "spec is missing paths") } pathItem := doc.Paths.Value(path) if pathItem == nil { require.Failf(t, "test failed", "spec is missing path %s", path) } op := pathItem.GetOperation(method) if op == nil { require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path) } return op } func responseSchemaRef(t *testing.T, op *openapi3.Operation, status int) *openapi3.SchemaRef { t.Helper() ref := op.Responses.Status(status) if ref == nil || ref.Value == nil { require.Failf(t, "test failed", "operation is missing %d response", status) } mt := ref.Value.Content.Get("application/json") if mt == nil || mt.Schema == nil { require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status) } return mt.Schema } func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef { t.Helper() if doc.Components.Schemas == nil { require.FailNow(t, "spec is missing component schemas") } ref := doc.Components.Schemas[name] if ref == nil { require.Failf(t, "test failed", "spec is missing component schema %s", name) } return ref } func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want, name string) { t.Helper() require.NotNil(t, schemaRef, "%s schema ref", name) require.Equal(t, want, schemaRef.Ref, "%s schema ref", name) } func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) { t.Helper() require.NotNil(t, schemaRef) require.ElementsMatch(t, fields, schemaRef.Value.Required) } func assertStringEnum(t *testing.T, schemaRef *openapi3.SchemaRef, property string, values ...string) { t.Helper() require.NotNil(t, schemaRef) propRef := schemaRef.Value.Properties[property] require.NotNil(t, propRef, "schema property %s", property) got := make([]string, 0, len(propRef.Value.Enum)) for _, v := range propRef.Value.Enum { got = append(got, v.(string)) } require.ElementsMatch(t, values, got) }