diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4cd0803e..c32c533c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,5 +137,8 @@ jobs: - name: Build CLI run: make single + - name: Download OpenAPI spec + run: make pkg/mockapi/testdata/upsun-openapi.json + - name: Run integration tests run: make integration-test diff --git a/.gitignore b/.gitignore index ce600154a..c288cf1fe 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ completion # vendor/ /.goreleaser.vendor.yaml legacy/box.phar + +# OpenAPI specification (auto-downloaded) +pkg/mockapi/testdata/upsun-openapi.json diff --git a/Makefile b/Makefile index e05d2f7f6..17af5db66 100644 --- a/Makefile +++ b/Makefile @@ -128,7 +128,7 @@ test: ## Run unit tests (excludes integration tests) GOEXPERIMENT=jsonv2 go test -v -race -cover -count=1 $$(go list ./... | grep -v /integration-tests) .PHONY: integration-test -integration-test: single ## Run integration tests (requires built CLI) +integration-test: single pkg/mockapi/testdata/upsun-openapi.json ## Run integration tests (requires built CLI and OpenAPI spec) cd integration-tests && GOEXPERIMENT=jsonv2 go test -v -count=1 ./... .PHONY: lint @@ -165,3 +165,13 @@ vendor-snapshot: check-vendor .goreleaser.vendor.yaml goreleaser internal/legacy .PHONY: goreleaser-check goreleaser-check: goreleaser ## Check the goreleaser configs PHP_VERSION=$(PHP_VERSION) goreleaser check --config=.goreleaser.yaml + +# OpenAPI Specification +OPENAPI_URL ?= https://developer.upsun.com/openapi.json + +# Download OpenAPI spec (only if missing, use 'rm' to force refresh) +pkg/mockapi/testdata/upsun-openapi.json: + @mkdir -p pkg/mockapi/testdata + @echo "Downloading OpenAPI spec from $(OPENAPI_URL)..." + @curl -fSL "$(OPENAPI_URL)" -o $@ + @echo "OpenAPI spec downloaded to $@" diff --git a/go.mod b/go.mod index 6c5c820ff..f23dd492c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/alecthomas/chroma/v2 v2.20.0 github.com/briandowns/spinner v1.23.2 github.com/fatih/color v1.18.0 + github.com/getkin/kin-openapi v0.134.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-playground/validator/v10 v10.27.0 github.com/gofrs/flock v0.12.1 @@ -61,6 +62,8 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -76,6 +79,7 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.17.11 // indirect @@ -91,10 +95,14 @@ require ( github.com/minio/minlz v1.0.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -118,6 +126,7 @@ require ( github.com/ulikunitz/xz v0.5.12 // indirect github.com/wasilibs/go-re2 v1.9.0 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/go.sum b/go.sum index 9f64acbc3..a1143aeeb 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= +github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= @@ -132,6 +134,10 @@ github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77 github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -140,6 +146,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -181,6 +189,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -207,6 +217,8 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -258,6 +270,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= @@ -265,6 +279,10 @@ github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= +github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= @@ -272,6 +290,8 @@ github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlR github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -346,6 +366,8 @@ github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+x github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -359,6 +381,8 @@ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8S github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= diff --git a/pkg/mockapi/api_server.go b/pkg/mockapi/api_server.go index a054f1c2c..acb596178 100644 --- a/pkg/mockapi/api_server.go +++ b/pkg/mockapi/api_server.go @@ -28,6 +28,17 @@ func NewHandler(t *testing.T) *Handler { h.Use(middleware.DefaultLogger) } + // Set Content-Type header for all JSON responses + h.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, req) + }) + }) + + // Add OpenAPI validation middleware (enabled with VALIDATE_OPENAPI=1) + h.Use(OpenAPIValidationMiddleware(t)) + h.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { authHeader := req.Header.Get("Authorization") diff --git a/pkg/mockapi/openapi_validation.go b/pkg/mockapi/openapi_validation.go new file mode 100644 index 000000000..319abaa4f --- /dev/null +++ b/pkg/mockapi/openapi_validation.go @@ -0,0 +1,155 @@ +package mockapi + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + + "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" +) + +var ( + openAPIDoc *openapi3.T + router routers.Router + loadOnce sync.Once + loadErr error +) + +// findModuleRoot walks up the directory tree to find go.mod +func findModuleRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found") + } + dir = parent + } +} + +// loadOpenAPISpec loads and validates the OpenAPI spec (once per test run) +func loadOpenAPISpec() error { + loadOnce.Do(func() { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + + // Find module root and construct path to spec + moduleRoot, err := findModuleRoot() + if err != nil { + loadErr = fmt.Errorf("failed to find module root: %w", err) + return + } + + specPath := filepath.Join(moduleRoot, "pkg/mockapi/testdata/upsun-openapi.json") + + openAPIDoc, loadErr = loader.LoadFromFile(specPath) + if loadErr != nil { + return + } + + loadErr = openAPIDoc.Validate(loader.Context) + if loadErr != nil { + return + } + + // Remove servers section for mock testing + // The spec defines servers as "https://api.upsun.com" but our mock runs on localhost + openAPIDoc.Servers = nil + + router, loadErr = legacy.NewRouter(openAPIDoc) + }) + return loadErr +} + +// OpenAPIValidationMiddleware validates mock API responses against OpenAPI spec +// Enable by setting VALIDATE_OPENAPI=1 environment variable +func OpenAPIValidationMiddleware(t testing.TB) func(http.Handler) http.Handler { + // Only validate if explicitly enabled + if os.Getenv("VALIDATE_OPENAPI") == "" { + return func(next http.Handler) http.Handler { + return next + } + } + + if err := loadOpenAPISpec(); err != nil { + t.Logf("Warning: OpenAPI validation disabled - failed to load spec: %v", err) + return func(next http.Handler) http.Handler { + return next + } + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Find route in OpenAPI spec + route, pathParams, err := router.FindRoute(r) + if err != nil { + t.Logf("Warning: Route not in OpenAPI spec: %s %s - %v", r.Method, r.URL.Path, err) + next.ServeHTTP(w, r) + return + } + + // Capture response + rec := &responseCapture{ + ResponseWriter: w, + body: &bytes.Buffer{}, + statusCode: http.StatusOK, // Default status + } + next.ServeHTTP(rec, r) + + // Validate response against OpenAPI schema + responseValidationInput := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + }, + Status: rec.statusCode, + Header: rec.Header(), + Body: io.NopCloser(bytes.NewReader(rec.body.Bytes())), + Options: &openapi3filter.Options{ + IncludeResponseStatus: true, + }, + } + + if err := openapi3filter.ValidateResponse(context.Background(), responseValidationInput); err != nil { + t.Errorf("OpenAPI validation failed for %s %s (status %d):\n%v\nResponse body:\n%s", + r.Method, r.URL.Path, rec.statusCode, err, rec.body.String()) + } + }) + } +} + +// responseCapture captures the response for validation +type responseCapture struct { + http.ResponseWriter + body *bytes.Buffer + statusCode int +} + +func (r *responseCapture) Write(b []byte) (int, error) { + r.body.Write(b) + return r.ResponseWriter.Write(b) +} + +func (r *responseCapture) WriteHeader(statusCode int) { + r.statusCode = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} + diff --git a/pkg/mockapi/openapi_validation_test.go b/pkg/mockapi/openapi_validation_test.go new file mode 100644 index 000000000..4e3bc5afd --- /dev/null +++ b/pkg/mockapi/openapi_validation_test.go @@ -0,0 +1,170 @@ +package mockapi_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/upsun/cli/pkg/mockapi" +) + +func TestOpenAPIValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping OpenAPI validation in short mode") + } + + // Enable OpenAPI validation for this test + os.Setenv("VALIDATE_OPENAPI", "1") + defer os.Unsetenv("VALIDATE_OPENAPI") + + handler := mockapi.NewHandler(t) + + // Setup some test data + projectID := mockapi.ProjectID() + orgID := "org-" + mockapi.NumericID() + + handler.SetMyUser(&mockapi.User{ + ID: "user-123", + Username: "testuser", + Email: "test@example.com", + FirstName: "Test", + LastName: "User", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + + handler.SetOrgs([]*mockapi.Org{ + { + ID: orgID, + Name: "test-org", + Label: "Test Organization", + Owner: "user-123", + Type: "organization", + Links: mockapi.MakeHALLinks( + "self=/organizations/"+orgID, + ), + }, + }) + + handler.SetProjects([]*mockapi.Project{ + { + ID: projectID, + Title: "Test Project", + Region: "us-1", + Organization: orgID, + DefaultBranch: "main", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + ), + }, + }) + + handler.SetEnvironments([]*mockapi.Environment{ + { + ID: "main", + Name: "main", + MachineName: "main", + Title: "Main", + Type: "production", + Status: "active", + Project: projectID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/environments/main", + ), + }, + }) + + server := httptest.NewServer(handler) + defer server.Close() + + // Test cases that should validate against OpenAPI spec + testCases := []struct { + name string + method string + path string + wantStatus int + }{ + { + name: "get current user", + method: "GET", + path: "/users/me", + wantStatus: http.StatusOK, + }, + { + name: "list organizations", + method: "GET", + path: "/organizations", + wantStatus: http.StatusOK, + }, + { + name: "get organization", + method: "GET", + path: "/organizations/" + orgID, + wantStatus: http.StatusOK, + }, + { + name: "get project", + method: "GET", + path: "/projects/" + projectID, + wantStatus: http.StatusOK, + }, + { + name: "list environments", + method: "GET", + path: "/projects/" + projectID + "/environments", + wantStatus: http.StatusOK, + }, + { + name: "get environment", + method: "GET", + path: "/projects/" + projectID + "/environments/main", + wantStatus: http.StatusOK, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(tc.method, server.URL+tc.path, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer test-token") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tc.wantStatus, resp.StatusCode) + + // Verify we got valid JSON + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err, "Response should be valid JSON") + }) + } +} + +// TestOpenAPIValidationDisabledByDefault ensures validation is opt-in +func TestOpenAPIValidationDisabledByDefault(t *testing.T) { + // Ensure VALIDATE_OPENAPI is not set + os.Unsetenv("VALIDATE_OPENAPI") + + handler := mockapi.NewHandler(t) + + // This should work without OpenAPI validation + req := httptest.NewRequest("GET", "/users/me", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + // Should succeed even without OpenAPI spec validation + assert.Equal(t, http.StatusOK, rec.Code) +}