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") } }