github.com/hashicorp/vault/sdk@v0.11.0/helper/testhelpers/schema/response_validation.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package schema
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"net/http"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/hashicorp/vault/sdk/framework"
    14  	"github.com/hashicorp/vault/sdk/logical"
    15  )
    16  
    17  // ValidateResponse is a test helper that validates whether the given response
    18  // object conforms to the response schema (schema.Fields). It cycles through
    19  // the data map and validates conversions in the schema. In "strict" mode, this
    20  // function will also ensure that the data map has all schema-required fields
    21  // and does not have any fields outside of the schema.
    22  func ValidateResponse(t *testing.T, schema *framework.Response, response *logical.Response, strict bool) {
    23  	t.Helper()
    24  
    25  	if response != nil {
    26  		ValidateResponseData(t, schema, response.Data, strict)
    27  	} else {
    28  		ValidateResponseData(t, schema, nil, strict)
    29  	}
    30  }
    31  
    32  // ValidateResponseData is a test helper that validates whether the given
    33  // response data map conforms to the response schema (schema.Fields). It cycles
    34  // through the data map and validates conversions in the schema. In "strict"
    35  // mode, this function will also ensure that the data map has all schema's
    36  // requred fields and does not have any fields outside of the schema.
    37  func ValidateResponseData(t *testing.T, schema *framework.Response, data map[string]interface{}, strict bool) {
    38  	t.Helper()
    39  
    40  	if err := validateResponseDataImpl(
    41  		schema,
    42  		data,
    43  		strict,
    44  	); err != nil {
    45  		t.Fatalf("validation error: %v; response data: %#v", err, data)
    46  	}
    47  }
    48  
    49  // validateResponseDataImpl is extracted so that it can be tested
    50  func validateResponseDataImpl(schema *framework.Response, data map[string]interface{}, strict bool) error {
    51  	// nothing to validate
    52  	if schema == nil {
    53  		return nil
    54  	}
    55  
    56  	// Certain responses may come through with non-2xx status codes. While
    57  	// these are not always errors (e.g. 3xx redirection codes), we don't
    58  	// consider them for the purposes of schema validation
    59  	if status, exists := data[logical.HTTPStatusCode]; exists {
    60  		s, ok := status.(int)
    61  		if ok && (s < 200 || s > 299) {
    62  			return nil
    63  		}
    64  	}
    65  
    66  	// Marshal the data to JSON and back to convert the map's values into
    67  	// JSON strings expected by Validate() and ValidateStrict(). This is
    68  	// not efficient and is done for testing purposes only.
    69  	jsonBytes, err := json.Marshal(data)
    70  	if err != nil {
    71  		return fmt.Errorf("failed to convert input to json: %w", err)
    72  	}
    73  
    74  	var dataWithStringValues map[string]interface{}
    75  	if err := json.Unmarshal(
    76  		jsonBytes,
    77  		&dataWithStringValues,
    78  	); err != nil {
    79  		return fmt.Errorf("failed to unmashal data: %w", err)
    80  	}
    81  
    82  	// these are special fields that will not show up in the final response and
    83  	// should be ignored
    84  	for _, field := range []string{
    85  		logical.HTTPContentType,
    86  		logical.HTTPRawBody,
    87  		logical.HTTPStatusCode,
    88  		logical.HTTPRawBodyAlreadyJSONDecoded,
    89  		logical.HTTPCacheControlHeader,
    90  		logical.HTTPPragmaHeader,
    91  		logical.HTTPWWWAuthenticateHeader,
    92  	} {
    93  		delete(dataWithStringValues, field)
    94  
    95  		if _, ok := schema.Fields[field]; ok {
    96  			return fmt.Errorf("encountered a reserved field in response schema: %s", field)
    97  		}
    98  	}
    99  
   100  	// Validate
   101  	fd := framework.FieldData{
   102  		Raw:    dataWithStringValues,
   103  		Schema: schema.Fields,
   104  	}
   105  
   106  	if strict {
   107  		return fd.ValidateStrict()
   108  	}
   109  
   110  	return fd.Validate()
   111  }
   112  
   113  // FindResponseSchema is a test helper to extract response schema from the
   114  // given framework path / operation.
   115  func FindResponseSchema(t *testing.T, paths []*framework.Path, pathIdx int, operation logical.Operation) *framework.Response {
   116  	t.Helper()
   117  
   118  	if pathIdx >= len(paths) {
   119  		t.Fatalf("path index %d is out of range", pathIdx)
   120  	}
   121  
   122  	schemaPath := paths[pathIdx]
   123  
   124  	return GetResponseSchema(t, schemaPath, operation)
   125  }
   126  
   127  func GetResponseSchema(t *testing.T, path *framework.Path, operation logical.Operation) *framework.Response {
   128  	t.Helper()
   129  
   130  	schemaOperation, ok := path.Operations[operation]
   131  	if !ok {
   132  		t.Fatalf(
   133  			"could not find response schema: %s: %q operation does not exist",
   134  			path.Pattern,
   135  			operation,
   136  		)
   137  	}
   138  
   139  	var schemaResponses []framework.Response
   140  
   141  	for _, status := range []int{
   142  		http.StatusOK,        // 200
   143  		http.StatusAccepted,  // 202
   144  		http.StatusNoContent, // 204
   145  	} {
   146  		schemaResponses, ok = schemaOperation.Properties().Responses[status]
   147  		if ok {
   148  			break
   149  		}
   150  	}
   151  
   152  	if len(schemaResponses) == 0 {
   153  		// ListOperations have a default response schema that is implicit unless overridden
   154  		if operation == logical.ListOperation {
   155  			return &framework.Response{
   156  				Description: "OK",
   157  				Fields: map[string]*framework.FieldSchema{
   158  					"keys": {
   159  						Type: framework.TypeStringSlice,
   160  					},
   161  				},
   162  			}
   163  		}
   164  
   165  		t.Fatalf(
   166  			"could not find response schema: %s: %q operation: no responses found",
   167  			path.Pattern,
   168  			operation,
   169  		)
   170  	}
   171  
   172  	return &schemaResponses[0]
   173  }
   174  
   175  // ResponseValidatingCallback can be used in setting up a [vault.TestCluster]
   176  // that validates every response against the openapi specifications.
   177  //
   178  // [vault.TestCluster]: https://pkg.go.dev/github.com/hashicorp/vault/vault#TestCluster
   179  func ResponseValidatingCallback(t *testing.T) func(logical.Backend, *logical.Request, *logical.Response) {
   180  	type PathRouter interface {
   181  		Route(string) *framework.Path
   182  	}
   183  
   184  	return func(b logical.Backend, req *logical.Request, resp *logical.Response) {
   185  		t.Helper()
   186  
   187  		if b == nil {
   188  			t.Fatalf("non-nil backend required")
   189  		}
   190  
   191  		backend, ok := b.(PathRouter)
   192  		if !ok {
   193  			t.Fatalf("could not cast %T to have `Route(string) *framework.Path`", b)
   194  		}
   195  
   196  		// The full request path includes the backend but when passing to the
   197  		// backend, we have to trim the mount point:
   198  		//   `sys/mounts/secret` -> `mounts/secret`
   199  		//   `auth/token/create` -> `create`
   200  		requestPath := strings.TrimPrefix(req.Path, req.MountPoint)
   201  
   202  		route := backend.Route(requestPath)
   203  		if route == nil {
   204  			t.Fatalf("backend %T could not find a route for %s", b, req.Path)
   205  		}
   206  
   207  		ValidateResponse(
   208  			t,
   209  			GetResponseSchema(t, route, req.Operation),
   210  			resp,
   211  			true,
   212  		)
   213  	}
   214  }