289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
|
|
package validate
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/runtime"
|
||
|
|
)
|
||
|
|
|
||
|
|
const hallContract = `
|
||
|
|
openapi: "3.1.0"
|
||
|
|
paths:
|
||
|
|
/v1/hall-entries:
|
||
|
|
post:
|
||
|
|
operationId: publishEntry
|
||
|
|
requestBody:
|
||
|
|
required: true
|
||
|
|
content:
|
||
|
|
application/json:
|
||
|
|
schema:
|
||
|
|
type: object
|
||
|
|
required: [id, title, body]
|
||
|
|
additionalProperties: false
|
||
|
|
properties:
|
||
|
|
id: {type: string, minLength: 1}
|
||
|
|
title: {type: string, maxLength: 120}
|
||
|
|
body: {type: string, maxLength: 4096}
|
||
|
|
format: {type: string, enum: [teaser, full]}
|
||
|
|
get:
|
||
|
|
operationId: listEntries
|
||
|
|
parameters:
|
||
|
|
- name: limit
|
||
|
|
in: query
|
||
|
|
schema: {type: integer, minimum: 1, maximum: 100}
|
||
|
|
/v1/hall-entries/latest:
|
||
|
|
get:
|
||
|
|
operationId: latestEntry
|
||
|
|
/v1/hall-entries/{id}:
|
||
|
|
get:
|
||
|
|
operationId: getEntry
|
||
|
|
parameters:
|
||
|
|
- name: id
|
||
|
|
in: path
|
||
|
|
required: true
|
||
|
|
schema: {type: string, minLength: 1}
|
||
|
|
`
|
||
|
|
|
||
|
|
func testRevision() contract.Revision {
|
||
|
|
return contract.Revision{
|
||
|
|
ID: "R-1",
|
||
|
|
Contract: contract.RevisionContract{Digest: contract.Digest("sha256:" + strings.Repeat("1", 64))},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func newValidator(t *testing.T) *OpenAPIValidator {
|
||
|
|
t.Helper()
|
||
|
|
v := NewOpenAPIValidator()
|
||
|
|
if err := v.Register(testRevision().Contract.Digest, []byte(hallContract)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return v
|
||
|
|
}
|
||
|
|
|
||
|
|
func request(method, target, body string) *http.Request {
|
||
|
|
var r *http.Request
|
||
|
|
if body == "" {
|
||
|
|
r = httptest.NewRequest(method, target, nil)
|
||
|
|
} else {
|
||
|
|
r = httptest.NewRequest(method, target, strings.NewReader(body))
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
}
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestValidRequestPasses(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
body := `{"id":"e-1","title":"the river reached the forge","body":"...","format":"teaser"}`
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil {
|
||
|
|
t.Fatalf("a conforming request was rejected: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRequiredFieldsEnforced(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
body := `{"id":"e-1"}`
|
||
|
|
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("a request missing required fields was accepted")
|
||
|
|
}
|
||
|
|
for _, want := range []string{"title", "body"} {
|
||
|
|
if !strings.Contains(err.Error(), want) {
|
||
|
|
t.Errorf("error does not name missing %q: %v", want, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLengthLimitIsCountedInRunes is the case this framework exists to publish:
|
||
|
|
// a hall entry with accented characters must not be rejected for a byte count
|
||
|
|
// it never exceeded.
|
||
|
|
func TestLengthLimitIsCountedInRunes(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
|
||
|
|
// 3000 multi-byte runes: well under the 4096 rune limit, well over it in bytes.
|
||
|
|
body := `{"id":"e-1","title":"t","body":"` + strings.Repeat("é", 3000) + `"}`
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err != nil {
|
||
|
|
t.Fatalf("a 3000-character entry was rejected against a 4096-character limit: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
over := `{"id":"e-1","title":"t","body":"` + strings.Repeat("a", 4097) + `"}`
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", over), []byte(over)); err == nil {
|
||
|
|
t.Error("a 4097-character entry passed a 4096-character limit")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUnknownFieldRejectedWhenClosed(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
body := `{"id":"e-1","title":"t","body":"b","urgency":"high"}`
|
||
|
|
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("an undeclared field was accepted")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "urgency") {
|
||
|
|
t.Errorf("error does not name the unknown field: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEnumEnforced(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
body := `{"id":"e-1","title":"t","body":"b","format":"interpretive-dance"}`
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body)); err == nil {
|
||
|
|
t.Error("a value outside the enum was accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestLiteralRoutesBeatTemplates: /latest must not be swallowed by /{id}.
|
||
|
|
func TestLiteralRoutesBeatTemplates(t *testing.T) {
|
||
|
|
c, err := ParseOpenAPI([]byte(hallContract))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
matched, _, ok := c.match("/v1/hall-entries/latest")
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("no route matched /latest")
|
||
|
|
}
|
||
|
|
if matched.template != "/v1/hall-entries/latest" {
|
||
|
|
t.Errorf("matched %q; the literal route must win over the template", matched.template)
|
||
|
|
}
|
||
|
|
|
||
|
|
matched, vars, ok := c.match("/v1/hall-entries/e-7")
|
||
|
|
if !ok {
|
||
|
|
t.Fatal("no route matched an id")
|
||
|
|
}
|
||
|
|
if matched.template != "/v1/hall-entries/{id}" || vars["id"] != "e-7" {
|
||
|
|
t.Errorf("template match wrong: %q vars %v", matched.template, vars)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestUnknownPathAndMethodRejected(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
|
||
|
|
err := v.Validate(testRevision(), request(http.MethodGet, "/v1/nope", ""), nil)
|
||
|
|
if err == nil {
|
||
|
|
t.Error("an undeclared path was accepted")
|
||
|
|
}
|
||
|
|
|
||
|
|
err = v.Validate(testRevision(), request(http.MethodDelete, "/v1/hall-entries", ""), nil)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("an undeclared method was accepted")
|
||
|
|
}
|
||
|
|
// The error should say what is allowed; a bare rejection teaches nothing.
|
||
|
|
if !strings.Contains(err.Error(), "GET") || !strings.Contains(err.Error(), "POST") {
|
||
|
|
t.Errorf("error does not name the allowed methods: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestQueryParametersAreCoercedAndBounded(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=10", ""), nil); err != nil {
|
||
|
|
t.Errorf("a valid numeric query parameter was rejected: %v", err)
|
||
|
|
}
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=500", ""), nil); err == nil {
|
||
|
|
t.Error("a query parameter over its maximum was accepted")
|
||
|
|
}
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries?limit=lots", ""), nil); err == nil {
|
||
|
|
t.Error("a non-numeric value for an integer parameter was accepted")
|
||
|
|
}
|
||
|
|
// An absent optional parameter is fine.
|
||
|
|
if err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil); err != nil {
|
||
|
|
t.Errorf("an absent optional parameter was rejected: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestUnregisteredContractIsRefused: serving a revision whose contract cannot
|
||
|
|
// be found would mean serving undeclared semantics.
|
||
|
|
func TestUnregisteredContractIsRefused(t *testing.T) {
|
||
|
|
v := NewOpenAPIValidator()
|
||
|
|
err := v.Validate(testRevision(), request(http.MethodGet, "/v1/hall-entries", ""), nil)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("a revision with no registered contract was served")
|
||
|
|
}
|
||
|
|
var ve *runtime.ValidationError
|
||
|
|
if !errors.As(err, &ve) {
|
||
|
|
t.Errorf("got %T, want *runtime.ValidationError", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMalformedBodyReportedClearly(t *testing.T) {
|
||
|
|
v := newValidator(t)
|
||
|
|
body := `{"id": "e-1",`
|
||
|
|
err := v.Validate(testRevision(), request(http.MethodPost, "/v1/hall-entries", body), []byte(body))
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("malformed JSON was accepted")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "valid JSON") {
|
||
|
|
t.Errorf("error is not clear about the cause: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRemoteRefsAreRefused(t *testing.T) {
|
||
|
|
// Resolving a remote reference would make validation depend on a network
|
||
|
|
// fetch from the request path.
|
||
|
|
c, err := ParseOpenAPI([]byte(hallContract))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, err := c.Resolve("https://example.com/schema.json"); !errors.Is(err, ErrUnsupported) {
|
||
|
|
t.Errorf("a remote $ref was accepted: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLocalRefsResolve(t *testing.T) {
|
||
|
|
doc := `
|
||
|
|
openapi: "3.1.0"
|
||
|
|
components:
|
||
|
|
schemas:
|
||
|
|
Entry:
|
||
|
|
type: object
|
||
|
|
required: [id]
|
||
|
|
properties:
|
||
|
|
id: {type: string}
|
||
|
|
paths:
|
||
|
|
/v1/entries:
|
||
|
|
post:
|
||
|
|
requestBody:
|
||
|
|
required: true
|
||
|
|
content:
|
||
|
|
application/json:
|
||
|
|
schema: {$ref: "#/components/schemas/Entry"}
|
||
|
|
`
|
||
|
|
v := NewOpenAPIValidator()
|
||
|
|
digest := contract.Digest("sha256:" + strings.Repeat("2", 64))
|
||
|
|
if err := v.Register(digest, []byte(doc)); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rev := testRevision()
|
||
|
|
rev.Contract.Digest = digest
|
||
|
|
|
||
|
|
if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{"id":"e-1"}`), []byte(`{"id":"e-1"}`)); err != nil {
|
||
|
|
t.Errorf("a valid referenced body was rejected: %v", err)
|
||
|
|
}
|
||
|
|
if err := v.Validate(rev, request(http.MethodPost, "/v1/entries", `{}`), []byte(`{}`)); err == nil {
|
||
|
|
t.Error("a body missing a required referenced field was accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOperationsListedForComplexityMeasurement(t *testing.T) {
|
||
|
|
c, err := ParseOpenAPI([]byte(hallContract))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
ops := c.Operations()
|
||
|
|
if len(ops) != 4 {
|
||
|
|
t.Errorf("operations = %v, want 4", ops)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEmptyContractRefused(t *testing.T) {
|
||
|
|
if _, err := ParseOpenAPI([]byte(`openapi: "3.1.0"`)); err == nil {
|
||
|
|
t.Error("a contract declaring no paths was accepted")
|
||
|
|
}
|
||
|
|
}
|