368 lines
11 KiB
Go
368 lines
11 KiB
Go
package internalhttp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/rtmanager/internal/api/internalhttp/handlers"
|
|
domainruntime "galaxy/rtmanager/internal/domain/runtime"
|
|
"galaxy/rtmanager/internal/ports"
|
|
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
|
"galaxy/rtmanager/internal/service/patchruntime"
|
|
"galaxy/rtmanager/internal/service/restartruntime"
|
|
"galaxy/rtmanager/internal/service/startruntime"
|
|
"galaxy/rtmanager/internal/service/stopruntime"
|
|
|
|
"github.com/getkin/kin-openapi/openapi3"
|
|
"github.com/getkin/kin-openapi/openapi3filter"
|
|
"github.com/getkin/kin-openapi/routers"
|
|
"github.com/getkin/kin-openapi/routers/legacy"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestInternalRESTConformance loads the OpenAPI specification, drives
|
|
// every runtime operation against the live internal HTTP listener
|
|
// backed by stub services, and validates each response body against
|
|
// the spec via `openapi3filter.ValidateResponse`. The test catches
|
|
// drift between the wire shape produced by the handler layer and the
|
|
// frozen contract; failure-path response shapes are validated by the
|
|
// per-handler tests in `handlers/<op>_test.go`.
|
|
func TestInternalRESTConformance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadConformanceSpec(t)
|
|
|
|
router, err := legacy.NewRouter(doc)
|
|
require.NoError(t, err)
|
|
|
|
deps := newConformanceDeps(t)
|
|
server, err := NewServer(newConformanceConfig(), Dependencies{
|
|
Logger: nil,
|
|
Telemetry: nil,
|
|
Readiness: nil,
|
|
RuntimeRecords: deps.records,
|
|
StartRuntime: deps.start,
|
|
StopRuntime: deps.stop,
|
|
RestartRuntime: deps.restart,
|
|
PatchRuntime: deps.patch,
|
|
CleanupContainer: deps.cleanup,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cases := []conformanceCase{
|
|
{
|
|
name: "internalListRuntimes",
|
|
method: http.MethodGet,
|
|
path: "/api/v1/internal/runtimes",
|
|
},
|
|
{
|
|
name: "internalGetRuntime",
|
|
method: http.MethodGet,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID,
|
|
},
|
|
{
|
|
name: "internalStartRuntime",
|
|
method: http.MethodPost,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/start",
|
|
contentType: "application/json",
|
|
body: `{"image_ref":"galaxy/game:v1.2.3"}`,
|
|
},
|
|
{
|
|
name: "internalStopRuntime",
|
|
method: http.MethodPost,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
|
|
contentType: "application/json",
|
|
body: `{"reason":"admin_request"}`,
|
|
},
|
|
{
|
|
name: "internalRestartRuntime",
|
|
method: http.MethodPost,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/restart",
|
|
},
|
|
{
|
|
name: "internalPatchRuntime",
|
|
method: http.MethodPost,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
|
|
contentType: "application/json",
|
|
body: `{"image_ref":"galaxy/game:v1.2.4"}`,
|
|
},
|
|
{
|
|
name: "internalCleanupRuntimeContainer",
|
|
method: http.MethodDelete,
|
|
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/container",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
runConformanceCase(t, server.handler, router, tc)
|
|
})
|
|
}
|
|
}
|
|
|
|
// conformanceGameID is the path variable used for every per-game
|
|
// conformance request.
|
|
const conformanceGameID = "game-conformance"
|
|
|
|
// conformanceServerURL mirrors the canonical `servers[0].url` entry in
|
|
// `rtmanager/api/internal-openapi.yaml`. The legacy router matches
|
|
// requests against this prefix; updating the spec's server URL
|
|
// requires updating this constant.
|
|
const conformanceServerURL = "http://localhost:8096"
|
|
|
|
// conformanceCase describes one request the conformance test drives.
|
|
type conformanceCase struct {
|
|
name string
|
|
method string
|
|
path string
|
|
contentType string
|
|
body string
|
|
}
|
|
|
|
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
|
|
t.Helper()
|
|
|
|
// Drive the handler with the path-only form so the listener's
|
|
// http.ServeMux matches the registered routes (which use raw paths,
|
|
// without the OpenAPI server URL prefix).
|
|
var bodyReader io.Reader
|
|
if tc.body != "" {
|
|
bodyReader = strings.NewReader(tc.body)
|
|
}
|
|
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
|
|
if tc.contentType != "" {
|
|
request.Header.Set("Content-Type", tc.contentType)
|
|
}
|
|
request.Header.Set("X-Galaxy-Caller", "admin")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, request)
|
|
require.Equalf(t, http.StatusOK, recorder.Code, "operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
|
|
|
|
// kin-openapi's legacy router requires the request URL to match a
|
|
// `servers[].url` entry; rebuild the validation request with the
|
|
// canonical local server URL declared in the spec.
|
|
validationURL := conformanceServerURL + tc.path
|
|
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
|
|
if tc.contentType != "" {
|
|
validationRequest.Header.Set("Content-Type", tc.contentType)
|
|
}
|
|
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
|
|
|
|
route, pathParams, err := router.FindRoute(validationRequest)
|
|
require.NoError(t, err)
|
|
|
|
requestInput := &openapi3filter.RequestValidationInput{
|
|
Request: validationRequest,
|
|
PathParams: pathParams,
|
|
Route: route,
|
|
Options: &openapi3filter.Options{
|
|
IncludeResponseStatus: true,
|
|
},
|
|
}
|
|
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
|
|
|
|
responseInput := &openapi3filter.ResponseValidationInput{
|
|
RequestValidationInput: requestInput,
|
|
Status: recorder.Code,
|
|
Header: recorder.Header(),
|
|
Options: &openapi3filter.Options{
|
|
IncludeResponseStatus: true,
|
|
},
|
|
}
|
|
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
|
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
|
|
}
|
|
|
|
func loadConformanceSpec(t *testing.T) *openapi3.T {
|
|
t.Helper()
|
|
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
require.True(t, ok)
|
|
|
|
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
|
|
loader := openapi3.NewLoader()
|
|
doc, err := loader.LoadFromFile(specPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, doc.Validate(context.Background()))
|
|
return doc
|
|
}
|
|
|
|
func bodyReaderFor(raw string) io.Reader {
|
|
if raw == "" {
|
|
return http.NoBody
|
|
}
|
|
return bytes.NewBufferString(raw)
|
|
}
|
|
|
|
// conformanceDeps groups the stub collaborators handed to the listener.
|
|
type conformanceDeps struct {
|
|
records *conformanceRecords
|
|
start *conformanceStart
|
|
stop *conformanceStop
|
|
restart *conformanceRestart
|
|
patch *conformancePatch
|
|
cleanup *conformanceCleanup
|
|
}
|
|
|
|
func newConformanceDeps(t *testing.T) *conformanceDeps {
|
|
t.Helper()
|
|
return &conformanceDeps{
|
|
records: newConformanceRecords(),
|
|
start: &conformanceStart{},
|
|
stop: &conformanceStop{},
|
|
restart: &conformanceRestart{},
|
|
patch: &conformancePatch{},
|
|
cleanup: &conformanceCleanup{},
|
|
}
|
|
}
|
|
|
|
func newConformanceConfig() Config {
|
|
return Config{
|
|
Addr: ":0",
|
|
ReadHeaderTimeout: time.Second,
|
|
ReadTimeout: time.Second,
|
|
WriteTimeout: time.Second,
|
|
IdleTimeout: time.Second,
|
|
}
|
|
}
|
|
|
|
// conformanceRecord builds a canonical running record used by every
|
|
// stub service.
|
|
func conformanceRecord() domainruntime.RuntimeRecord {
|
|
started := time.Date(2026, 4, 26, 13, 0, 0, 0, time.UTC)
|
|
return domainruntime.RuntimeRecord{
|
|
GameID: conformanceGameID,
|
|
Status: domainruntime.StatusRunning,
|
|
CurrentContainerID: "container-conformance",
|
|
CurrentImageRef: "galaxy/game:v1.2.3",
|
|
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
|
|
StatePath: "/var/lib/galaxy/" + conformanceGameID,
|
|
DockerNetwork: "galaxy-engine",
|
|
StartedAt: &started,
|
|
LastOpAt: started,
|
|
CreatedAt: started,
|
|
}
|
|
}
|
|
|
|
// conformanceRecords is an in-memory record store seeded with one
|
|
// canonical record so the get / list endpoints have something to
|
|
// return.
|
|
type conformanceRecords struct {
|
|
mu sync.Mutex
|
|
stored map[string]domainruntime.RuntimeRecord
|
|
}
|
|
|
|
func newConformanceRecords() *conformanceRecords {
|
|
return &conformanceRecords{
|
|
stored: map[string]domainruntime.RuntimeRecord{
|
|
conformanceGameID: conformanceRecord(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *conformanceRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
record, ok := s.stored[gameID]
|
|
if !ok {
|
|
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (s *conformanceRecords) Upsert(_ context.Context, _ domainruntime.RuntimeRecord) error {
|
|
return errors.New("not used in conformance test")
|
|
}
|
|
|
|
func (s *conformanceRecords) UpdateStatus(_ context.Context, _ ports.UpdateStatusInput) error {
|
|
return errors.New("not used in conformance test")
|
|
}
|
|
|
|
func (s *conformanceRecords) ListByStatus(_ context.Context, _ domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
|
|
return nil, errors.New("not used in conformance test")
|
|
}
|
|
|
|
func (s *conformanceRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
|
for _, record := range s.stored {
|
|
out = append(out, record)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// conformanceStart is the stub StartService used by the conformance
|
|
// test. Every Handle call returns the canonical record.
|
|
type conformanceStart struct{}
|
|
|
|
func (s *conformanceStart) Handle(_ context.Context, _ startruntime.Input) (startruntime.Result, error) {
|
|
return startruntime.Result{
|
|
Record: conformanceRecord(),
|
|
Outcome: "success",
|
|
}, nil
|
|
}
|
|
|
|
type conformanceStop struct{}
|
|
|
|
func (s *conformanceStop) Handle(_ context.Context, _ stopruntime.Input) (stopruntime.Result, error) {
|
|
rec := conformanceRecord()
|
|
rec.Status = domainruntime.StatusStopped
|
|
stopped := rec.LastOpAt.Add(time.Second)
|
|
rec.StoppedAt = &stopped
|
|
rec.LastOpAt = stopped
|
|
return stopruntime.Result{Record: rec, Outcome: "success"}, nil
|
|
}
|
|
|
|
type conformanceRestart struct{}
|
|
|
|
func (s *conformanceRestart) Handle(_ context.Context, _ restartruntime.Input) (restartruntime.Result, error) {
|
|
return restartruntime.Result{Record: conformanceRecord(), Outcome: "success"}, nil
|
|
}
|
|
|
|
type conformancePatch struct{}
|
|
|
|
func (s *conformancePatch) Handle(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
|
rec := conformanceRecord()
|
|
if in.NewImageRef != "" {
|
|
rec.CurrentImageRef = in.NewImageRef
|
|
}
|
|
return patchruntime.Result{Record: rec, Outcome: "success"}, nil
|
|
}
|
|
|
|
type conformanceCleanup struct{}
|
|
|
|
func (s *conformanceCleanup) Handle(_ context.Context, _ cleanupcontainer.Input) (cleanupcontainer.Result, error) {
|
|
rec := conformanceRecord()
|
|
rec.Status = domainruntime.StatusRemoved
|
|
rec.CurrentContainerID = ""
|
|
removed := rec.LastOpAt.Add(time.Minute)
|
|
rec.RemovedAt = &removed
|
|
rec.LastOpAt = removed
|
|
return cleanupcontainer.Result{Record: rec, Outcome: "success"}, nil
|
|
}
|
|
|
|
// Compile-time guards: the stubs must satisfy the handler-level
|
|
// service ports plus ports.RuntimeRecordStore so the listener accepts
|
|
// them.
|
|
var (
|
|
_ handlers.StartService = (*conformanceStart)(nil)
|
|
_ handlers.StopService = (*conformanceStop)(nil)
|
|
_ handlers.RestartService = (*conformanceRestart)(nil)
|
|
_ handlers.PatchService = (*conformancePatch)(nil)
|
|
_ handlers.CleanupService = (*conformanceCleanup)(nil)
|
|
_ ports.RuntimeRecordStore = (*conformanceRecords)(nil)
|
|
)
|