github.com/w3security/vervet/v5@v5.3.1-0.20230618081846-5bd9b5d799dc/versionware/validator_test.go (about)

     1  package versionware_test
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"regexp"
    11  	"testing"
    12  	"time"
    13  
    14  	qt "github.com/frankban/quicktest"
    15  	"github.com/getkin/kin-openapi/openapi3"
    16  	"github.com/getkin/kin-openapi/openapi3filter"
    17  
    18  	"github.com/w3security/vervet/v5/versionware"
    19  )
    20  
    21  const (
    22  	v20210820 = `
    23  openapi: 3.0.0
    24  x-w3security-api-version: '2021-08-20'
    25  info:
    26    title: 'Validator'
    27    version: '0.0.0'
    28  paths:
    29    /test/{id}:
    30      get:
    31        operationId: getTest
    32        description: get a test
    33        parameters:
    34          - in: path
    35            name: id
    36            schema:
    37              type: string
    38            required: true
    39          - in: query
    40            name: version
    41            schema:
    42              type: string
    43            required: true
    44        responses:
    45          '200':
    46            description: 'respond with test resource'
    47            content:
    48              application/json:
    49                schema: { $ref: '#/components/schemas/TestResource' }
    50          '400': { $ref: '#/components/responses/ErrorResponse' }
    51          '404': { $ref: '#/components/responses/ErrorResponse' }
    52          '500': { $ref: '#/components/responses/ErrorResponse' }
    53  components:
    54    schemas:
    55      TestContents:
    56        type: object
    57        properties:
    58          name:
    59            type: string
    60          expected:
    61            type: number
    62          actual:
    63            type: number
    64        required: [name, expected, actual]
    65        additionalProperties: false
    66      TestResource:
    67        type: object
    68        properties:
    69          id:
    70            type: string
    71          contents:
    72            { $ref: '#/components/schemas/TestContents' }
    73        required: [id, contents]
    74        additionalProperties: false
    75      Error:
    76        type: object
    77        properties:
    78          code:
    79            type: string
    80          message:
    81            type: string
    82        required: [code, message]
    83        additionalProperties: false
    84    responses:
    85      ErrorResponse:
    86        description: 'an error occurred'
    87        content:
    88          application/json:
    89            schema: { $ref: '#/components/schemas/Error' }
    90  `
    91  	v20210916 = `
    92  openapi: 3.0.0
    93  x-w3security-api-version: '2021-09-16'
    94  info:
    95    title: 'Validator'
    96    version: '0.0.0'
    97  paths:
    98    /test:
    99      post:
   100        operationId: newTest
   101        description: create a new test
   102        parameters:
   103          - in: query
   104            name: version
   105            schema:
   106              type: string
   107            required: true
   108        requestBody:
   109          required: true
   110          content:
   111            application/json:
   112              schema: { $ref: '#/components/schemas/TestContents' }
   113        responses:
   114          '201':
   115            description: 'created test'
   116            content:
   117              application/json:
   118                schema: { $ref: '#/components/schemas/TestResource' }
   119          '400': { $ref: '#/components/responses/ErrorResponse' }
   120          '500': { $ref: '#/components/responses/ErrorResponse' }
   121    /test/{id}:
   122      get:
   123        operationId: getTest
   124        description: get a test
   125        parameters:
   126          - in: path
   127            name: id
   128            schema:
   129              type: string
   130            required: true
   131          - in: query
   132            name: version
   133            schema:
   134              type: string
   135            required: true
   136        responses:
   137          '200':
   138            description: 'respond with test resource'
   139            content:
   140              application/json:
   141                schema: { $ref: '#/components/schemas/TestResource' }
   142          '400': { $ref: '#/components/responses/ErrorResponse' }
   143          '404': { $ref: '#/components/responses/ErrorResponse' }
   144          '500': { $ref: '#/components/responses/ErrorResponse' }
   145  components:
   146    schemas:
   147      TestContents:
   148        type: object
   149        properties:
   150          name:
   151            type: string
   152          expected:
   153            type: number
   154          actual:
   155            type: number
   156          noodles:
   157            type: boolean
   158        required: [name, expected, actual, noodles]
   159        additionalProperties: false
   160      TestResource:
   161        type: object
   162        properties:
   163          id:
   164            type: string
   165          contents:
   166            { $ref: '#/components/schemas/TestContents' }
   167        required: [id, contents]
   168        additionalProperties: false
   169      Error:
   170        type: object
   171        properties:
   172          code:
   173            type: string
   174          message:
   175            type: string
   176        required: [code, message]
   177        additionalProperties: false
   178    responses:
   179      ErrorResponse:
   180        description: 'an error occurred'
   181        content:
   182          application/json:
   183            schema: { $ref: '#/components/schemas/Error' }
   184  `
   185  )
   186  
   187  type validatorTestHandler struct {
   188  	contentType       string
   189  	getBody, postBody string
   190  	errBody           string
   191  	errStatusCode     int
   192  }
   193  
   194  const v20210916_Body = `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10, "noodles": true}}`
   195  
   196  func (h validatorTestHandler) withDefaults() validatorTestHandler {
   197  	if h.contentType == "" {
   198  		h.contentType = "application/json"
   199  	}
   200  	if h.getBody == "" {
   201  		h.getBody = v20210916_Body
   202  	}
   203  	if h.postBody == "" {
   204  		h.postBody = v20210916_Body
   205  	}
   206  	if h.errBody == "" {
   207  		h.errBody = `{"code":"bad","message":"bad things"}`
   208  	}
   209  	return h
   210  }
   211  
   212  var testUrlRE = regexp.MustCompile(`^/test(/\d+)?$`)
   213  
   214  func (h *validatorTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   215  	w.Header().Set("Content-Type", h.contentType)
   216  	if h.errStatusCode != 0 {
   217  		w.WriteHeader(h.errStatusCode)
   218  		_, err := w.Write([]byte(h.errBody))
   219  		if err != nil {
   220  			panic(err)
   221  		}
   222  		return
   223  	}
   224  	if !testUrlRE.MatchString(r.URL.Path) {
   225  		w.WriteHeader(http.StatusNotFound)
   226  		_, err := w.Write([]byte(h.errBody))
   227  		if err != nil {
   228  			panic(err)
   229  		}
   230  		return
   231  	}
   232  	switch r.Method {
   233  	case "GET":
   234  		w.WriteHeader(http.StatusOK)
   235  		_, err := w.Write([]byte(h.getBody))
   236  		if err != nil {
   237  			panic(err)
   238  		}
   239  	case "POST":
   240  		w.WriteHeader(http.StatusCreated)
   241  		_, err := w.Write([]byte(h.postBody))
   242  		if err != nil {
   243  			panic(err)
   244  		}
   245  	default:
   246  		http.Error(w, h.errBody, http.StatusMethodNotAllowed)
   247  	}
   248  }
   249  
   250  func TestValidator(t *testing.T) {
   251  	c := qt.New(t)
   252  	ctx := context.Background()
   253  	docs := make([]*openapi3.T, 2)
   254  	for i, specStr := range []string{v20210820, v20210916} {
   255  		doc, err := openapi3.NewLoader().LoadFromData([]byte(specStr))
   256  		c.Assert(err, qt.IsNil)
   257  		err = doc.Validate(ctx)
   258  		c.Assert(err, qt.IsNil)
   259  		docs[i] = doc
   260  	}
   261  
   262  	type testRequest struct {
   263  		method, path, body, contentType string
   264  	}
   265  	type testResponse struct {
   266  		statusCode int
   267  		body       string
   268  	}
   269  	tests := []struct {
   270  		name     string
   271  		handler  validatorTestHandler
   272  		options  []openapi3filter.ValidatorOption
   273  		request  testRequest
   274  		response testResponse
   275  		strict   bool
   276  	}{{
   277  		name:    "valid GET",
   278  		handler: validatorTestHandler{}.withDefaults(),
   279  		request: testRequest{
   280  			method: "GET",
   281  			path:   "/test/42?version=2021-09-17",
   282  		},
   283  		response: testResponse{
   284  			200, v20210916_Body,
   285  		},
   286  		strict: true,
   287  	}, {
   288  		name:    "valid POST",
   289  		handler: validatorTestHandler{}.withDefaults(),
   290  		request: testRequest{
   291  			method:      "POST",
   292  			path:        "/test?version=2021-09-17",
   293  			body:        `{"name": "foo", "expected": 9, "actual": 10, "noodles": true}`,
   294  			contentType: "application/json",
   295  		},
   296  		response: testResponse{
   297  			201, v20210916_Body,
   298  		},
   299  		strict: true,
   300  	}, {
   301  		name:    "not found; no GET operation for /test",
   302  		handler: validatorTestHandler{}.withDefaults(),
   303  		request: testRequest{
   304  			method: "GET",
   305  			path:   "/test?version=2021-09-17",
   306  		},
   307  		response: testResponse{
   308  			404, "Not Found\n",
   309  		},
   310  		strict: true,
   311  	}, {
   312  		name:    "not found; no POST operation for /test/42",
   313  		handler: validatorTestHandler{}.withDefaults(),
   314  		request: testRequest{
   315  			method: "POST",
   316  			path:   "/test/42?version=2021-09-17",
   317  		},
   318  		response: testResponse{
   319  			404, "Not Found\n",
   320  		},
   321  		strict: true,
   322  	}, {
   323  		name:    "invalid request; missing version",
   324  		handler: validatorTestHandler{}.withDefaults(),
   325  		request: testRequest{
   326  			method: "GET",
   327  			path:   "/test/42",
   328  		},
   329  		response: testResponse{
   330  			400, "Bad Request\n",
   331  		},
   332  		strict: true,
   333  	}, {
   334  		name:    "invalid POST request; wrong property type",
   335  		handler: validatorTestHandler{}.withDefaults(),
   336  		request: testRequest{
   337  			method:      "POST",
   338  			path:        "/test?version=2021-09-17",
   339  			body:        `{"name": "foo", "expected": "nine", "actual": "ten", "noodles": false}`,
   340  			contentType: "application/json",
   341  		},
   342  		response: testResponse{
   343  			400, "Bad Request\n",
   344  		},
   345  		strict: true,
   346  	}, {
   347  		name:    "invalid POST request; missing property",
   348  		handler: validatorTestHandler{}.withDefaults(),
   349  		request: testRequest{
   350  			method:      "POST",
   351  			path:        "/test?version=2021-09-17",
   352  			body:        `{"name": "foo", "expected": 9}`,
   353  			contentType: "application/json",
   354  		},
   355  		response: testResponse{
   356  			400, "Bad Request\n",
   357  		},
   358  		strict: true,
   359  	}, {
   360  		name:    "invalid POST request; extra property",
   361  		handler: validatorTestHandler{}.withDefaults(),
   362  		request: testRequest{
   363  			method:      "POST",
   364  			path:        "/test?version=2021-09-17",
   365  			body:        `{"name": "foo", "expected": 9, "actual": 10, "noodles": false, "ideal": 8}`,
   366  			contentType: "application/json",
   367  		},
   368  		response: testResponse{
   369  			400, "Bad Request\n",
   370  		},
   371  		strict: true,
   372  	}, {
   373  		name: "valid response; 404 error",
   374  		handler: validatorTestHandler{
   375  			contentType:   "application/json",
   376  			errBody:       `{"code": "404", "message": "not found"}`,
   377  			errStatusCode: 404,
   378  		}.withDefaults(),
   379  		request: testRequest{
   380  			method: "GET",
   381  			path:   "/test/42?version=2021-09-17",
   382  		},
   383  		response: testResponse{
   384  			404, `{"code": "404", "message": "not found"}`,
   385  		},
   386  		strict: true,
   387  	}, {
   388  		name: "invalid response; invalid error",
   389  		handler: validatorTestHandler{
   390  			errBody:       `"not found"`,
   391  			errStatusCode: 404,
   392  		}.withDefaults(),
   393  		request: testRequest{
   394  			method: "GET",
   395  			path:   "/test/42?version=2021-09-17",
   396  		},
   397  		response: testResponse{
   398  			500, "Internal Server Error\n",
   399  		},
   400  		strict: true,
   401  	}, {
   402  		name: "invalid POST response; not strict",
   403  		handler: validatorTestHandler{
   404  			postBody: `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`,
   405  		}.withDefaults(),
   406  		request: testRequest{
   407  			method:      "POST",
   408  			path:        "/test?version=2021-09-17",
   409  			body:        `{"name": "foo", "expected": 9, "actual": 10, "noodles": true}`,
   410  			contentType: "application/json",
   411  		},
   412  		response: testResponse{
   413  			statusCode: 201,
   414  			body:       `{"id": "42", "contents": {"name": "foo", "expected": 9, "actual": 10}, "extra": true}`,
   415  		},
   416  		strict: false,
   417  	}, {
   418  		name:    "invalid GET for API in the future",
   419  		handler: validatorTestHandler{}.withDefaults(),
   420  		request: testRequest{
   421  			method: "GET",
   422  			path:   "/test/42?version=2023-09-17",
   423  		},
   424  		response: testResponse{
   425  			400, "Bad Request\n",
   426  		},
   427  		strict: true,
   428  	}}
   429  	for i, test := range tests {
   430  		c.Run(fmt.Sprintf("%d %s", i, test.name), func(c *qt.C) {
   431  			// Set up a test HTTP server
   432  			var h http.Handler
   433  			s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   434  				h.ServeHTTP(w, r)
   435  			}))
   436  			defer s.Close()
   437  
   438  			config := versionware.DefaultValidatorConfig
   439  			config.ServerURL = s.URL
   440  			config.Options = append(config.Options, append(test.options, openapi3filter.Strict(test.strict))...)
   441  			v, err := versionware.NewValidator(&config, docs...)
   442  			c.Assert(err, qt.IsNil)
   443  			v.SetToday(func() time.Time {
   444  				return time.Date(2022, time.January, 21, 0, 0, 0, 0, time.UTC)
   445  			})
   446  			h = v.Middleware(&test.handler)
   447  
   448  			// Test: make a client request
   449  			var requestBody io.Reader
   450  			if test.request.body != "" {
   451  				requestBody = bytes.NewBufferString(test.request.body)
   452  			}
   453  			req, err := http.NewRequest(test.request.method, s.URL+test.request.path, requestBody)
   454  			c.Assert(err, qt.IsNil)
   455  
   456  			if test.request.contentType != "" {
   457  				req.Header.Set("Content-Type", test.request.contentType)
   458  			}
   459  			resp, err := s.Client().Do(req)
   460  			c.Assert(err, qt.IsNil)
   461  			defer resp.Body.Close()
   462  			c.Assert(test.response.statusCode, qt.Equals, resp.StatusCode)
   463  
   464  			body, err := io.ReadAll(resp.Body)
   465  			c.Assert(err, qt.IsNil)
   466  			c.Assert(test.response.body, qt.Equals, string(body))
   467  		})
   468  	}
   469  }
   470  
   471  func TestValidatorConfig(t *testing.T) {
   472  	c := qt.New(t)
   473  
   474  	// No specs provided
   475  	_, err := versionware.NewValidator(&versionware.ValidatorConfig{ServerURL: "://"})
   476  	c.Assert(err, qt.ErrorMatches, `no OpenAPI versions provided`)
   477  
   478  	// Invalid server URL
   479  	_, err = versionware.NewValidator(&versionware.ValidatorConfig{ServerURL: "://"}, &openapi3.T{})
   480  	c.Assert(err, qt.ErrorMatches, `invalid ServerURL: parse "://": missing protocol scheme`)
   481  
   482  	// Missing version in OpenAPI spec
   483  	_, err = versionware.NewValidator(&versionware.ValidatorConfig{ServerURL: "http://example.com"}, &openapi3.T{})
   484  	c.Assert(err, qt.ErrorMatches, `extension "x-w3security-api-version" not found`)
   485  
   486  	docs := make([]*openapi3.T, 2)
   487  	for i, specStr := range []string{v20210820, v20210916} {
   488  		doc, err := openapi3.NewLoader().LoadFromData([]byte(specStr))
   489  		c.Assert(err, qt.IsNil)
   490  		err = doc.Validate(context.Background())
   491  		c.Assert(err, qt.IsNil)
   492  		docs[i] = doc
   493  	}
   494  
   495  	// Invalid server URL
   496  	_, err = versionware.NewValidator(&versionware.ValidatorConfig{ServerURL: "localhost:8080"}, docs...)
   497  	c.Assert(
   498  		err,
   499  		qt.ErrorMatches,
   500  		`invalid ServerURL: unsupported scheme "localhost" \(did you forget to specify the scheme://\?\)`,
   501  	)
   502  
   503  	// Valid
   504  	_, err = versionware.NewValidator(&versionware.ValidatorConfig{ServerURL: "http://localhost:8080"}, docs...)
   505  	c.Assert(err, qt.IsNil)
   506  	c.Assert(docs[0].Servers[0].URL, qt.Equals, "http://localhost:8080")
   507  }