fluid-core/internal/validate/openapi_test.go
tegwick 61d8d8cabe Add the OpenAPI contract validator, closing FLUID-WP-0003
The gateway can now enforce a revision's declared contract, which makes
"a deterministic API contract" -- the first minimal-conformance
requirement -- something the framework actually checks rather than
assumes.

The JSON Schema support is a documented subset. Keywords outside it are
reported as unsupported rather than skipped, because a validator that
silently ignores a constraint it does not understand is worse than none:
it reports success it did not earn. The same reasoning refuses remote
$refs, which would make request-path validation depend on a network
fetch, and refuses to serve a revision whose contract is not registered.

String lengths are counted in runes. A 4096-character limit that
rejected a 3000-character hall entry because of its accents would be
wrong in exactly the case this framework was built to publish.

Literal routes are matched before templated ones, so /entries/latest is
not swallowed by /entries/{id} -- which matters, since a latest-entry
convenience route is the Blueprint's own worked example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 08:14:19 +02:00

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