github.com/icyphox/x@v0.0.355-0.20220311094250-029bd783e8b8/decoderx/http_test.go (about)

     1  package decoderx
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"net/url"
    12  	"sync"
    13  	"testing"
    14  
    15  	"github.com/ory/x/assertx"
    16  
    17  	"github.com/tidwall/gjson"
    18  
    19  	"github.com/pkg/errors"
    20  	"github.com/stretchr/testify/assert"
    21  	"github.com/stretchr/testify/require"
    22  
    23  	"github.com/ory/jsonschema/v3"
    24  )
    25  
    26  var ctx = context.Background()
    27  
    28  func newRequest(t *testing.T, method, url string, body io.Reader, ct string) *http.Request {
    29  	req := httptest.NewRequest(method, url, body)
    30  	req.Header.Set("Content-Type", ct)
    31  	return req
    32  }
    33  
    34  func TestHTTPFormDecoder(t *testing.T) {
    35  	for k, tc := range []struct {
    36  		d             string
    37  		request       *http.Request
    38  		contentType   string
    39  		options       []HTTPDecoderOption
    40  		expected      string
    41  		expectedError string
    42  	}{
    43  		{
    44  			d:             "should fail because the method is GET",
    45  			request:       &http.Request{Header: map[string][]string{}, Method: "GET"},
    46  			expectedError: "HTTP Request Method",
    47  		},
    48  		{
    49  			d:             "should fail because the body is empty",
    50  			request:       &http.Request{Header: map[string][]string{}, Method: "POST"},
    51  			expectedError: "Content-Length",
    52  		},
    53  		{
    54  			d:             "should fail because content type is missing",
    55  			request:       newRequest(t, "POST", "/", nil, ""),
    56  			expectedError: "Content-Length",
    57  		},
    58  		{
    59  			d:             "should fail because content type is missing",
    60  			request:       newRequest(t, "POST", "/", bytes.NewBufferString("foo"), ""),
    61  			expectedError: "Content-Type",
    62  		},
    63  		{
    64  			d:        "should pass with json without validation",
    65  			request:  newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
    66  			expected: `{"foo":"bar"}`,
    67  		},
    68  		{
    69  			d:             "should fail json if content type is not accepted",
    70  			request:       newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
    71  			options:       []HTTPDecoderOption{HTTPFormDecoder()},
    72  			expectedError: "Content-Type: application/json",
    73  		},
    74  		{
    75  			d:       "should fail json if validation fails",
    76  			request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar", "bar":"baz"}`), httpContentTypeJSON),
    77  			options: []HTTPDecoderOption{HTTPJSONDecoder(), MustHTTPRawJSONSchemaCompiler([]byte(`{
    78  	"$id": "https://example.com/config.schema.json",
    79  	"$schema": "http://json-schema.org/draft-07/schema#",
    80  	"type": "object",
    81  	"properties": {
    82  		"foo": {
    83  			"type": "number"
    84  		},
    85  		"bar": {
    86  			"type": "string"
    87  		}
    88  	}
    89  }`),
    90  			)},
    91  			expectedError: "expected number, but got string",
    92  			expected:      `{ "bar": "baz", "foo": "bar" }`,
    93  		},
    94  		{
    95  			d:       "should pass json with validation",
    96  			request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
    97  			options: []HTTPDecoderOption{HTTPJSONDecoder(), MustHTTPRawJSONSchemaCompiler([]byte(`{
    98  	"$id": "https://example.com/config.schema.json",
    99  	"$schema": "http://json-schema.org/draft-07/schema#",
   100  	"type": "object",
   101  	"properties": {
   102  		"foo": {
   103  			"type": "string"
   104  		}
   105  	}
   106  }`),
   107  			),
   108  			},
   109  			expected: `{"foo":"bar"}`,
   110  		},
   111  		{
   112  			d:             "should fail form request when form is used but only json is allowed",
   113  			request:       newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"foo": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
   114  			options:       []HTTPDecoderOption{HTTPJSONDecoder()},
   115  			expectedError: "Content-Type: application/x-www-form-urlencoded",
   116  		},
   117  		{
   118  			d:             "should fail form request when schema is missing",
   119  			request:       newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"foo": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
   120  			options:       []HTTPDecoderOption{},
   121  			expectedError: "no validation schema was provided",
   122  		},
   123  		{
   124  			d:             "should fail form request when schema does not validate request",
   125  			request:       newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"bar": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
   126  			options:       []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/schema.json", nil)},
   127  			expectedError: `missing properties: "foo"`,
   128  		},
   129  		{
   130  			d: "should pass form request and type assert data",
   131  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   132  				"name.first": {"Aeneas"},
   133  				"name.last":  {"Rekkas"},
   134  				"age":        {"29"},
   135  				"ratio":      {"0.9"},
   136  				"consent":    {"true"},
   137  
   138  				// newsletter represents a special case for checkbox input with true/false and raw HTML.
   139  				"newsletter": {
   140  					"false", // comes from <input type="hidden" name="newsletter" value="false">
   141  					"true",  // comes from <input type="checkbox" name="newsletter" value="true" checked>
   142  				},
   143  			}.Encode()), httpContentTypeURLEncodedForm),
   144  			options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
   145  			expected: `{
   146  	"name": {"first": "Aeneas", "last": "Rekkas"},
   147  	"age": 29,
   148  	"newsletter": true,
   149  	"consent": true,
   150  	"ratio": 0.9
   151  }`,
   152  		},
   153  		{
   154  			d: "should mark the correct fields when nested objects are required",
   155  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   156  				// newsletter represents a special case for checkbox input with true/false and raw HTML.
   157  				"foo": {"bar"},
   158  			}.Encode()), httpContentTypeURLEncodedForm),
   159  			options: []HTTPDecoderOption{
   160  				HTTPJSONSchemaCompiler("stub/consent.json", nil),
   161  				HTTPKeepRequestBody(true),
   162  				HTTPDecoderSetValidatePayloads(false),
   163  				HTTPDecoderUseQueryAndBody(),
   164  				HTTPDecoderAllowedMethods("POST", "GET"),
   165  				HTTPDecoderJSONFollowsFormFormat(),
   166  			},
   167  			expected: `{
   168    "traits": {
   169  	"consent": {
   170  	  "inner": {}
   171      },
   172  	"notrequired": {}
   173    }
   174  }`,
   175  		},
   176  		{
   177  			d: "should pass form request with payload in query and type assert data",
   178  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(url.Values{
   179  				"name.first": {"Aeneas"},
   180  				"name.last":  {"Rekkas"},
   181  				"ratio":      {"0.9"},
   182  				"consent":    {"true"},
   183  				// newsletter represents a special case for checkbox input with true/false and raw HTML.
   184  				"newsletter": {
   185  					"false", // comes from <input type="hidden" name="newsletter" value="false">
   186  					"true",  // comes from <input type="checkbox" name="newsletter" value="true" checked>
   187  				},
   188  			}.Encode()), httpContentTypeURLEncodedForm),
   189  			options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
   190  			expected: `{
   191  	"name": {"first": "Aeneas", "last": "Rekkas"},
   192  	"newsletter": true,
   193  	"consent": true,
   194  	"ratio": 0.9
   195  }`,
   196  		},
   197  		{
   198  			d: "should pass form request with payload in query and type assert data",
   199  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(url.Values{
   200  				"name.first": {"Aeneas"},
   201  				"name.last":  {"Rekkas"},
   202  				"ratio":      {"0.9"},
   203  				"consent":    {"true"},
   204  				// newsletter represents a special case for checkbox input with true/false and raw HTML.
   205  				"newsletter": {
   206  					"false", // comes from <input type="hidden" name="newsletter" value="false">
   207  					"true",  // comes from <input type="checkbox" name="newsletter" value="true" checked>
   208  				},
   209  			}.Encode()), httpContentTypeURLEncodedForm),
   210  			options: []HTTPDecoderOption{
   211  				HTTPDecoderUseQueryAndBody(),
   212  				HTTPJSONSchemaCompiler("stub/person.json", nil),
   213  			},
   214  			expected: `{
   215  	"name": {"first": "Aeneas", "last": "Rekkas"},
   216  	"age": 29,
   217  	"newsletter": true,
   218  	"consent": true,
   219  	"ratio": 0.9
   220  }`,
   221  		},
   222  		{
   223  			d: "should fail form request if empty values are sent because of required fields",
   224  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(url.Values{
   225  				"name.first":  {""},
   226  				"name.last":   {""},
   227  				"name2.first": {""},
   228  				"name2.last":  {""},
   229  				"ratio":       {""},
   230  				"ratio2":      {""},
   231  				"age":         {""},
   232  				"age2":        {""},
   233  				"consent":     {""},
   234  				"consent2":    {""},
   235  				// newsletter represents a special case for checkbox input with true/false and raw HTML.
   236  				"newsletter":  {""},
   237  				"newsletter2": {""},
   238  			}.Encode()), httpContentTypeURLEncodedForm),
   239  			options: []HTTPDecoderOption{
   240  				HTTPDecoderUseQueryAndBody(),
   241  				HTTPJSONSchemaCompiler("stub/required-defaults.json", nil),
   242  			},
   243  			expectedError: `I[#/name2] S[#/properties/name2/required] missing properties: "first"`,
   244  		},
   245  		{
   246  			d:             "should fail json request formatted as form if payload is invalid",
   247  			request:       newRequest(t, "POST", "/", bytes.NewBufferString(`{"name.first":"Aeneas", "name.last":"Rekkas","age":"not-a-number"}`), httpContentTypeJSON),
   248  			options:       []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
   249  			expectedError: "expected integer, but got string",
   250  		},
   251  		{
   252  			d: "should pass JSON request formatted as a form",
   253  			request: newRequest(t, "POST", "/", bytes.NewBufferString(`{
   254  	"name.first": "Aeneas",
   255  	"name.last":  "Rekkas",
   256  	"age":        29,
   257  	"ratio":      0.9,
   258  	"consent":    false,
   259  	"newsletter": true
   260  }`), httpContentTypeJSON),
   261  			options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
   262  				HTTPJSONSchemaCompiler("stub/person.json", nil)},
   263  			expected: `{
   264  	"name": {"first": "Aeneas", "last": "Rekkas"},
   265  	"age": 29,
   266  	"newsletter": true,
   267  	"consent": false,
   268  	"ratio": 0.9
   269  }`,
   270  		},
   271  		{
   272  			d: "should pass JSON request formatted as a form",
   273  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
   274  	"name.first": "Aeneas",
   275  	"name.last":  "Rekkas",
   276  	"ratio":      0.9,
   277  	"consent":    false,
   278  	"newsletter": true
   279  }`), httpContentTypeJSON),
   280  			options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
   281  				HTTPJSONSchemaCompiler("stub/person.json", nil)},
   282  			expected: `{
   283  	"name": {"first": "Aeneas", "last": "Rekkas"},
   284  	"newsletter": true,
   285  	"consent": false,
   286  	"ratio": 0.9
   287  }`,
   288  		},
   289  		{
   290  			d: "should pass JSON request formatted as a JSON even if HTTPDecoderJSONFollowsFormFormat is used",
   291  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
   292  	"name": {"first": "Aeneas", "last": "Rekkas"},
   293  	"ratio":      0.9,
   294  	"consent":    false,
   295  	"newsletter": true
   296  }`), httpContentTypeJSON),
   297  			options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
   298  				HTTPJSONSchemaCompiler("stub/person.json", nil)},
   299  			expected: `{
   300  	"name": {"first": "Aeneas", "last": "Rekkas"},
   301  	"newsletter": true,
   302  	"consent": false,
   303  	"ratio": 0.9
   304  }`,
   305  		},
   306  		{
   307  			d: "should not retry indefinitely if key does not exist",
   308  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
   309  	"not-foo": "bar"
   310  }`), httpContentTypeJSON),
   311  			options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
   312  				HTTPJSONSchemaCompiler("stub/schema.json", nil)},
   313  			expectedError: "I[#] S[#/required] missing properties",
   314  		},
   315  		{
   316  			d:       "should indicate the true missing fields from nested form",
   317  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"leaf": {"foo"}}.Encode()), httpContentTypeURLEncodedForm),
   318  			options: []HTTPDecoderOption{
   319  				HTTPDecoderUseQueryAndBody(),
   320  				HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorIgnoreConversionErrors),
   321  				HTTPJSONSchemaCompiler("stub/nested.json", nil)},
   322  			expectedError: `I[#/node/node/node] S[#/properties/node/properties/node/properties/node/required] missing properties: "leaf"`,
   323  		},
   324  		{
   325  			d: "should pass JSON request formatted as a form",
   326  			request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
   327  	"name.first": "Aeneas",
   328  	"name.last":  "Rekkas",
   329  	"ratio":      0.9,
   330  	"consent":    false,
   331  	"newsletter": true
   332  }`), httpContentTypeJSON),
   333  			options: []HTTPDecoderOption{
   334  				HTTPDecoderUseQueryAndBody(),
   335  				HTTPDecoderJSONFollowsFormFormat(),
   336  				HTTPJSONSchemaCompiler("stub/person.json", nil)},
   337  			expected: `{
   338  	"name": {"first": "Aeneas", "last": "Rekkas"},
   339  	"age": 29,
   340  	"newsletter": true,
   341  	"consent": false,
   342  	"ratio": 0.9
   343  }`,
   344  		},
   345  		{
   346  			d: "should pass JSON request GET request",
   347  			request: newRequest(t, "GET", "/?"+url.Values{
   348  				"name.first": {"Aeneas"},
   349  				"name.last":  {"Rekkas"},
   350  				"age":        {"29"},
   351  				"ratio":      {"0.9"},
   352  				"consent":    {"false"},
   353  				"newsletter": {"true"},
   354  			}.Encode(), nil, ""),
   355  			options: []HTTPDecoderOption{
   356  				HTTPJSONSchemaCompiler("stub/person.json", nil),
   357  				HTTPDecoderAllowedMethods("GET"),
   358  			},
   359  			expected: `{
   360  	"name": {"first": "Aeneas", "last": "Rekkas"},
   361  	"age": 29,
   362  	"newsletter": true,
   363  	"consent": false,
   364  	"ratio": 0.9
   365  }`,
   366  		},
   367  		{
   368  			d:       "should fail because json is not an object when using form format",
   369  			request: newRequest(t, "POST", "/", bytes.NewBufferString(`[]`), httpContentTypeJSON),
   370  			options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
   371  				HTTPJSONSchemaCompiler("stub/person.json", nil)},
   372  			expectedError: "be an object",
   373  		},
   374  		{
   375  			d: "should work with ParseErrorIgnoreConversionErrors",
   376  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   377  				"ratio": {"foobar"},
   378  			}.Encode()), httpContentTypeURLEncodedForm),
   379  			options: []HTTPDecoderOption{
   380  				HTTPJSONSchemaCompiler("stub/person.json", nil),
   381  				HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorIgnoreConversionErrors),
   382  				HTTPDecoderSetValidatePayloads(false),
   383  			},
   384  			expected: `{"name": {}, "ratio": "foobar"}`,
   385  		},
   386  		{
   387  			d: "should work with ParseErrorIgnoreConversionErrors",
   388  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   389  				"ratio": {"foobar"},
   390  			}.Encode()), httpContentTypeURLEncodedForm),
   391  			options:  []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorUseEmptyValueOnConversionErrors)},
   392  			expected: `{"name": {}, "ratio": 0.0}`,
   393  		},
   394  		{
   395  			d: "should work with ParseErrorIgnoreConversionErrors",
   396  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   397  				"ratio": {"foobar"},
   398  			}.Encode()), httpContentTypeURLEncodedForm),
   399  			options:       []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorReturnOnConversionErrors)},
   400  			expectedError: `strconv.ParseFloat: parsing "foobar"`,
   401  		},
   402  		{
   403  			d: "should interpret numbers as string if mandated by the schema",
   404  			request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
   405  				"name.first": {"12345"},
   406  			}.Encode()), httpContentTypeURLEncodedForm),
   407  			options:  []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorUseEmptyValueOnConversionErrors)},
   408  			expected: `{"name": {"first": "12345"}}`,
   409  		},
   410  	} {
   411  		t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) {
   412  			dec := NewHTTP()
   413  			var destination json.RawMessage
   414  			err := dec.Decode(tc.request, &destination, tc.options...)
   415  			if tc.expectedError != "" {
   416  				if e, ok := errors.Cause(err).(*jsonschema.ValidationError); ok {
   417  					t.Logf("%+v", e)
   418  				}
   419  				require.Error(t, err)
   420  				require.Contains(t, fmt.Sprintf("%+v", err), tc.expectedError)
   421  				if len(tc.expected) > 0 {
   422  					assert.JSONEq(t, tc.expected, string(destination))
   423  				}
   424  				return
   425  			}
   426  
   427  			require.NoError(t, err)
   428  			assertx.EqualAsJSON(t, json.RawMessage(tc.expected), destination)
   429  		})
   430  	}
   431  
   432  	t.Run("description=read body twice", func(t *testing.T) {
   433  		var wg sync.WaitGroup
   434  		wg.Add(1)
   435  
   436  		dec := NewHTTP()
   437  		ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   438  			defer wg.Done()
   439  
   440  			var destination json.RawMessage
   441  			require.NoError(t, dec.Decode(r, &destination, HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPKeepRequestBody(true)))
   442  			assert.EqualValues(t, "12345", gjson.GetBytes(destination, "name.first").String())
   443  
   444  			require.NoError(t, dec.Decode(r, &destination, HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPKeepRequestBody(true)))
   445  			assert.EqualValues(t, "12345", gjson.GetBytes(destination, "name.first").String())
   446  		}))
   447  		t.Cleanup(ts.Close)
   448  
   449  		_, err := ts.Client().PostForm(ts.URL, url.Values{"name.first": {"12345"}})
   450  		require.NoError(t, err)
   451  
   452  		wg.Wait()
   453  	})
   454  }