385 lines
12 KiB
Go
385 lines
12 KiB
Go
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)
|
|
}
|