diff --git a/changelog/19043.txt b/changelog/19043.txt new file mode 100644 index 000000000..20a1a77bb --- /dev/null +++ b/changelog/19043.txt @@ -0,0 +1,3 @@ +```release-note:improvement +openapi: added ability to validate response structures against openapi schema for test clusters +``` \ No newline at end of file diff --git a/sdk/helper/testhelpers/schema/response_validation.go b/sdk/helper/testhelpers/schema/response_validation.go index 2a2d6b3b5..1238c595e 100644 --- a/sdk/helper/testhelpers/schema/response_validation.go +++ b/sdk/helper/testhelpers/schema/response_validation.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "github.com/hashicorp/vault/sdk/framework" @@ -126,3 +127,43 @@ func GetResponseSchema(t *testing.T, path *framework.Path, operation logical.Ope return &schemaResponses[0] } + +// ResponseValidatingCallback can be used in setting up a [vault.TestCluster] that validates every response against the +// openapi specifications +// +// [vault.TestCluster]: https://pkg.go.dev/github.com/hashicorp/vault/vault#TestCluster +func ResponseValidatingCallback(t *testing.T) func(logical.Backend, *logical.Request, *logical.Response) { + type PathRouter interface { + Route(string) *framework.Path + } + + return func(b logical.Backend, req *logical.Request, resp *logical.Response) { + t.Helper() + + if b == nil { + t.Fatalf("non-nil backend required") + } + backend, ok := b.(PathRouter) + if !ok { + t.Fatalf("could not cast %T to have `Route(string) *framework.Path`", b) + } + + // the full request path includes the backend + // but when passing to the backend, we have to trim the mount point + // `sys/mounts/secret` -> `mounts/secret` + // `auth/token/create` -> `create` + requestPath := strings.TrimPrefix(req.Path, req.MountPoint) + + route := backend.Route(requestPath) + if route == nil { + t.Fatalf("backend %T could not find a route for %s", b, req.Path) + } + + ValidateResponse( + t, + GetResponseSchema(t, route, req.Operation), + resp, + true, + ) + } +} diff --git a/vault/core.go b/vault/core.go index 50077d673..45940ae94 100644 --- a/vault/core.go +++ b/vault/core.go @@ -688,6 +688,10 @@ type Core struct { // contains absolute paths that we intend to forward (and template) when // we're on a secondary cluster. writeForwardedPaths *pathmanager.PathManager + + // if populated, the callback is called for every request + // for testing purposes + requestResponseCallback func(logical.Backend, *logical.Request, *logical.Response) } // c.stateLock needs to be held in read mode before calling this function. diff --git a/vault/request_handling.go b/vault/request_handling.go index ff041838d..164956950 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -675,6 +675,10 @@ func (c *Core) handleCancelableRequest(ctx context.Context, req *logical.Request resp, auth, err = c.handleRequest(ctx, req) } + if err == nil && c.requestResponseCallback != nil { + c.requestResponseCallback(c.router.MatchingBackend(ctx, req.Path), req, resp) + } + // If we saved the token in the request, we should return it in the response // data. if resp != nil && resp.Data != nil { diff --git a/vault/testing.go b/vault/testing.go index da3c72e33..da11ae30c 100644 --- a/vault/testing.go +++ b/vault/testing.go @@ -1186,6 +1186,9 @@ type TestClusterOptions struct { NoDefaultQuotas bool Plugins *TestPluginConfig + + // if populated, the callback is called for every request + RequestResponseCallback func(logical.Backend, *logical.Request, *logical.Response) } type TestPluginConfig struct { @@ -1936,6 +1939,10 @@ func (testCluster *TestCluster) newCore(t testing.T, idx int, coreConfig *CoreCo handler = opts.HandlerFunc.Handler(&props) } + if opts != nil && opts.RequestResponseCallback != nil { + c.requestResponseCallback = opts.RequestResponseCallback + } + // Set this in case the Seal was manually set before the core was // created if localConfig.Seal != nil {