diff --git a/config/config.go b/config/config.go index 7d395c9..c2d384c 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,8 @@ package config import ( + "log/slog" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/pb33f/libopenapi-validator/cache" @@ -28,6 +30,13 @@ type ValidationOptions struct { AllowScalarCoercion bool // Enable string->boolean/number coercion Formats map[string]func(v any) error SchemaCache cache.SchemaCache // Optional cache for compiled schemas + Logger *slog.Logger // Logger for debug/error output (nil = silent) + + // strict mode options - detect undeclared properties even when additionalProperties: true + StrictMode bool // Enable strict property validation + StrictIgnorePaths []string // Instance JSONPath patterns to exclude from strict checks + StrictIgnoredHeaders []string // Headers to always ignore in strict mode (nil = use defaults) + strictIgnoredHeadersMerge bool // Internal: true if merging with defaults } // Option Enables an 'Options pattern' approach @@ -35,7 +44,7 @@ type Option func(*ValidationOptions) // NewValidationOptions creates a new ValidationOptions instance with default values. func NewValidationOptions(opts ...Option) *ValidationOptions { - // Create the set of default values + // create the set of default values o := &ValidationOptions{ FormatAssertions: false, ContentAssertions: false, @@ -44,14 +53,11 @@ func NewValidationOptions(opts ...Option) *ValidationOptions { SchemaCache: cache.NewDefaultCache(), // Enable caching by default } - // Apply any supplied overrides for _, opt := range opts { if opt != nil { opt(o) } } - - // Done return o } @@ -68,10 +74,23 @@ func WithExistingOpts(options *ValidationOptions) Option { o.AllowScalarCoercion = options.AllowScalarCoercion o.Formats = options.Formats o.SchemaCache = options.SchemaCache + o.Logger = options.Logger + o.StrictMode = options.StrictMode + o.StrictIgnorePaths = options.StrictIgnorePaths + o.StrictIgnoredHeaders = options.StrictIgnoredHeaders + o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge } } } +// WithLogger sets the logger for validation debug/error output. +// If not set, logging is silent (nil logger is handled gracefully). +func WithLogger(logger *slog.Logger) Option { + return func(o *ValidationOptions) { + o.Logger = logger + } +} + // WithRegexEngine Assigns a custom regular-expression engine to be used during validation. func WithRegexEngine(engine jsonschema.RegexpEngine) Option { return func(o *ValidationOptions) { @@ -150,3 +169,78 @@ func WithSchemaCache(cache cache.SchemaCache) Option { o.SchemaCache = cache } } + +// WithStrictMode enables strict property validation. +// In strict mode, undeclared properties are reported as errors even when +// additionalProperties: true would normally allow them. +// +// This is useful for API governance scenarios where you want to ensure +// clients only send properties that are explicitly documented in the +// OpenAPI specification. +func WithStrictMode() Option { + return func(o *ValidationOptions) { + o.StrictMode = true + } +} + +// WithStrictIgnorePaths sets JSONPath patterns for paths to exclude from strict validation. +// Patterns use glob syntax: +// - * matches a single path segment +// - ** matches any depth (zero or more segments) +// - [*] matches any array index +// - \* escapes a literal asterisk +// +// Examples: +// - "$.body.metadata.*" - any property under metadata +// - "$.body.**.x-*" - any x-* property at any depth +// - "$.headers.X-*" - any header starting with X- +func WithStrictIgnorePaths(paths ...string) Option { + return func(o *ValidationOptions) { + o.StrictIgnorePaths = paths + } +} + +// WithStrictIgnoredHeaders replaces the default ignored headers list entirely. +// Use this to fully control which headers are ignored in strict mode. +// For the default list, see the strict package's DefaultIgnoredHeaders. +func WithStrictIgnoredHeaders(headers ...string) Option { + return func(o *ValidationOptions) { + o.StrictIgnoredHeaders = headers + o.strictIgnoredHeadersMerge = false + } +} + +// WithStrictIgnoredHeadersExtra adds headers to the default ignored list. +// Unlike WithStrictIgnoredHeaders, this merges with the defaults rather +// than replacing them. +func WithStrictIgnoredHeadersExtra(headers ...string) Option { + return func(o *ValidationOptions) { + o.StrictIgnoredHeaders = headers + o.strictIgnoredHeadersMerge = true + } +} + +// defaultIgnoredHeaders contains standard HTTP headers ignored by default. +// This is the fallback list used when no custom headers are configured. +var defaultIgnoredHeaders = []string{ + "content-type", "content-length", "accept", "authorization", + "user-agent", "host", "connection", "accept-encoding", + "accept-language", "cache-control", "pragma", "origin", + "referer", "cookie", "date", "etag", "expires", + "if-match", "if-none-match", "if-modified-since", + "last-modified", "transfer-encoding", "vary", "x-forwarded-for", + "x-forwarded-proto", "x-real-ip", "x-request-id", +} + +// GetEffectiveStrictIgnoredHeaders returns the list of headers to ignore +// based on configuration. Returns defaults if not configured, merged list +// if extra headers were added, or replaced list if headers were fully replaced. +func (o *ValidationOptions) GetEffectiveStrictIgnoredHeaders() []string { + if o.StrictIgnoredHeaders == nil { + return defaultIgnoredHeaders + } + if o.strictIgnoredHeadersMerge { + return append(defaultIgnoredHeaders, o.StrictIgnoredHeaders...) + } + return o.StrictIgnoredHeaders +} diff --git a/config/config_test.go b/config/config_test.go index a79aa9c..dddd739 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -4,6 +4,7 @@ package config import ( + "log/slog" "sync" "testing" @@ -368,3 +369,105 @@ func TestWithRegexpCache(t *testing.T) { assert.NotNil(t, opts.RegexCache) } + +// Tests for strict mode configuration options + +func TestWithStrictMode(t *testing.T) { + opts := NewValidationOptions(WithStrictMode()) + + assert.True(t, opts.StrictMode) + assert.Nil(t, opts.StrictIgnorePaths) + assert.Nil(t, opts.StrictIgnoredHeaders) +} + +func TestWithStrictIgnorePaths(t *testing.T) { + paths := []string{"$.body.metadata.*", "$.headers.X-*"} + opts := NewValidationOptions(WithStrictIgnorePaths(paths...)) + + assert.Equal(t, paths, opts.StrictIgnorePaths) + assert.False(t, opts.StrictMode) // Not enabled by default +} + +func TestWithStrictIgnoredHeaders(t *testing.T) { + headers := []string{"x-custom-header", "x-another-header"} + opts := NewValidationOptions(WithStrictIgnoredHeaders(headers...)) + + assert.Equal(t, headers, opts.StrictIgnoredHeaders) + assert.False(t, opts.strictIgnoredHeadersMerge) +} + +func TestWithStrictIgnoredHeadersExtra(t *testing.T) { + headers := []string{"x-extra-header"} + opts := NewValidationOptions(WithStrictIgnoredHeadersExtra(headers...)) + + assert.Equal(t, headers, opts.StrictIgnoredHeaders) + assert.True(t, opts.strictIgnoredHeadersMerge) +} + +func TestGetEffectiveStrictIgnoredHeaders_Default(t *testing.T) { + opts := NewValidationOptions() + + headers := opts.GetEffectiveStrictIgnoredHeaders() + + assert.NotNil(t, headers) + assert.Contains(t, headers, "content-type") + assert.Contains(t, headers, "authorization") +} + +func TestGetEffectiveStrictIgnoredHeaders_Replace(t *testing.T) { + customHeaders := []string{"x-only-this"} + opts := NewValidationOptions(WithStrictIgnoredHeaders(customHeaders...)) + + headers := opts.GetEffectiveStrictIgnoredHeaders() + + assert.Equal(t, customHeaders, headers) + assert.NotContains(t, headers, "content-type") // Default headers are replaced +} + +func TestGetEffectiveStrictIgnoredHeaders_Merge(t *testing.T) { + extraHeaders := []string{"x-extra-header"} + opts := NewValidationOptions(WithStrictIgnoredHeadersExtra(extraHeaders...)) + + headers := opts.GetEffectiveStrictIgnoredHeaders() + + // Should have both defaults and extras + assert.Contains(t, headers, "content-type") // From defaults + assert.Contains(t, headers, "x-extra-header") // From extras + assert.Contains(t, headers, "authorization") // From defaults +} + +func TestWithLogger(t *testing.T) { + logger := slog.New(slog.NewTextHandler(nil, nil)) + opts := NewValidationOptions(WithLogger(logger)) + + assert.Equal(t, logger, opts.Logger) +} + +func TestWithExistingOpts_StrictFields(t *testing.T) { + original := &ValidationOptions{ + StrictMode: true, + StrictIgnorePaths: []string{"$.body.*"}, + StrictIgnoredHeaders: []string{"x-custom"}, + strictIgnoredHeadersMerge: true, + Logger: slog.New(slog.NewTextHandler(nil, nil)), + } + + opts := NewValidationOptions(WithExistingOpts(original)) + + assert.True(t, opts.StrictMode) + assert.Equal(t, original.StrictIgnorePaths, opts.StrictIgnorePaths) + assert.Equal(t, original.StrictIgnoredHeaders, opts.StrictIgnoredHeaders) + assert.True(t, opts.strictIgnoredHeadersMerge) + assert.Equal(t, original.Logger, opts.Logger) +} + +func TestStrictModeWithIgnorePaths(t *testing.T) { + paths := []string{"$.body.metadata.*"} + opts := NewValidationOptions( + WithStrictMode(), + WithStrictIgnorePaths(paths...), + ) + + assert.True(t, opts.StrictMode) + assert.Equal(t, paths, opts.StrictIgnorePaths) +} diff --git a/errors/parameter_errors.go b/errors/parameter_errors.go index 2f9768a..0e79952 100644 --- a/errors/parameter_errors.go +++ b/errors/parameter_errors.go @@ -102,6 +102,19 @@ func HeaderParameterMissing(param *v3.Parameter) *ValidationError { } } +func CookieParameterMissing(param *v3.Parameter) *ValidationError { + return &ValidationError{ + ValidationType: helpers.ParameterValidation, + ValidationSubType: helpers.ParameterValidationCookie, + Message: fmt.Sprintf("Cookie parameter '%s' is missing", param.Name), + Reason: fmt.Sprintf("The cookie parameter '%s' is defined as being required, "+ + "however it's missing from the request", param.Name), + SpecLine: param.GoLow().Required.KeyNode.Line, + SpecCol: param.GoLow().Required.KeyNode.Column, + HowToFix: HowToFixMissingValue, + } +} + func HeaderParameterCannotBeDecoded(param *v3.Parameter, val string) *ValidationError { return &ValidationError{ ValidationType: helpers.ParameterValidation, diff --git a/errors/parameter_errors_test.go b/errors/parameter_errors_test.go index 4f8304a..6d21993 100644 --- a/errors/parameter_errors_test.go +++ b/errors/parameter_errors_test.go @@ -145,6 +145,21 @@ func TestHeaderParameterMissing(t *testing.T) { require.Equal(t, HowToFixMissingValue, err.HowToFix) } +func TestCookieParameterMissing(t *testing.T) { + param := createMockParameterWithSchema() + + // Call the function + err := CookieParameterMissing(param) + + // Validate the error + require.NotNil(t, err) + require.Equal(t, helpers.ParameterValidation, err.ValidationType) + require.Equal(t, helpers.ParameterValidationCookie, err.ValidationSubType) + require.Contains(t, err.Message, "Cookie parameter 'testParam' is missing") + require.Contains(t, err.Reason, "'testParam' is defined as being required") + require.Equal(t, HowToFixMissingValue, err.HowToFix) +} + func TestHeaderParameterCannotBeDecoded(t *testing.T) { param := createMockParameterWithSchema() val := "malformed_header_value" diff --git a/errors/strict_errors.go b/errors/strict_errors.go new file mode 100644 index 0000000..8455e87 --- /dev/null +++ b/errors/strict_errors.go @@ -0,0 +1,150 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package errors + +import ( + "fmt" + "strings" +) + +// StrictValidationType is the validation type for strict mode errors. +const StrictValidationType = "strict" + +// StrictValidationSubTypes for different kinds of undeclared values. +const ( + StrictSubTypeProperty = "undeclared-property" + StrictSubTypeHeader = "undeclared-header" + StrictSubTypeQuery = "undeclared-query-param" + StrictSubTypeCookie = "undeclared-cookie" +) + +// UndeclaredPropertyError creates a ValidationError for an undeclared property. +func UndeclaredPropertyError( + path string, + name string, + value any, + declaredProperties []string, + direction string, + requestPath string, + requestMethod string, +) *ValidationError { + dirStr := direction + if dirStr == "" { + dirStr = "request" + } + + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeProperty, + Message: fmt.Sprintf("%s property '%s' at '%s' is not declared in schema", + dirStr, name, path), + Reason: fmt.Sprintf("Strict mode: found property not in schema. "+ + "Declared properties: [%s]", strings.Join(declaredProperties, ", ")), + HowToFix: fmt.Sprintf("Add '%s' to the schema, remove it from the %s, "+ + "or add '%s' to StrictIgnorePaths", name, dirStr, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + } +} + +// UndeclaredHeaderError creates a ValidationError for an undeclared header. +func UndeclaredHeaderError( + name string, + value string, + declaredHeaders []string, + direction string, + requestPath string, + requestMethod string, +) *ValidationError { + dirStr := direction + if dirStr == "" { + dirStr = "request" + } + + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeHeader, + Message: fmt.Sprintf("%s header '%s' is not declared in specification", + dirStr, name), + Reason: fmt.Sprintf("Strict mode: found header not in spec. "+ + "Declared headers: [%s]", strings.Join(declaredHeaders, ", ")), + HowToFix: fmt.Sprintf("Add '%s' to the operation's parameters, remove it from the %s, "+ + "or add it to StrictIgnoredHeaders", name, dirStr), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: value, + } +} + +// UndeclaredQueryParamError creates a ValidationError for an undeclared query parameter. +func UndeclaredQueryParamError( + path string, + name string, + value any, + declaredParams []string, + requestPath string, + requestMethod string, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeQuery, + Message: fmt.Sprintf("query parameter '%s' at '%s' is not declared in specification", name, path), + Reason: fmt.Sprintf("Strict mode: found query parameter not in spec. "+ + "Declared parameters: [%s]", strings.Join(declaredParams, ", ")), + HowToFix: fmt.Sprintf("Add '%s' to the operation's query parameters, remove it from the request, "+ + "or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + } +} + +// UndeclaredCookieError creates a ValidationError for an undeclared cookie. +func UndeclaredCookieError( + path string, + name string, + value any, + declaredCookies []string, + requestPath string, + requestMethod string, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeCookie, + Message: fmt.Sprintf("cookie '%s' at '%s' is not declared in specification", name, path), + Reason: fmt.Sprintf("Strict mode: found cookie not in spec. "+ + "Declared cookies: [%s]", strings.Join(declaredCookies, ", ")), + HowToFix: fmt.Sprintf("Add '%s' to the operation's cookie parameters, remove it from the request, "+ + "or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + } +} + +// truncateForContext creates a truncated string representation for error context. +func truncateForContext(v any) string { + switch val := v.(type) { + case string: + if len(val) > 50 { + return val[:47] + "..." + } + return val + case map[string]any: + return "{...}" + case []any: + return "[...]" + default: + s := fmt.Sprintf("%v", v) + if len(s) > 50 { + return s[:47] + "..." + } + return s + } +} diff --git a/errors/strict_errors_test.go b/errors/strict_errors_test.go new file mode 100644 index 0000000..6fc3624 --- /dev/null +++ b/errors/strict_errors_test.go @@ -0,0 +1,205 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package errors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUndeclaredPropertyError(t *testing.T) { + err := UndeclaredPropertyError( + "$.body.user.extra", + "extra", + "some value", + []string{"name", "email"}, + "request", + "/users", + "POST", + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeProperty, err.ValidationSubType) + assert.Contains(t, err.Message, "request property 'extra' at '$.body.user.extra'") + assert.Contains(t, err.Reason, "name, email") + assert.Contains(t, err.HowToFix, "extra") + assert.Contains(t, err.HowToFix, "$.body.user.extra") + assert.Equal(t, "/users", err.RequestPath) + assert.Equal(t, "POST", err.RequestMethod) + assert.Equal(t, "extra", err.ParameterName) +} + +func TestUndeclaredPropertyError_Response(t *testing.T) { + err := UndeclaredPropertyError( + "$.body.data.undeclared", + "undeclared", + map[string]any{"nested": "value"}, + []string{"id", "name"}, + "response", + "/items/123", + "GET", + ) + + assert.NotNil(t, err) + assert.Contains(t, err.Message, "response property 'undeclared'") + assert.Contains(t, err.Reason, "id, name") + assert.Equal(t, "{...}", err.Context) // Map truncated +} + +func TestUndeclaredPropertyError_EmptyDirection(t *testing.T) { + err := UndeclaredPropertyError( + "$.body.prop", + "prop", + "value", + nil, + "", // Empty direction defaults to "request" + "/test", + "POST", + ) + + assert.Contains(t, err.Message, "request property") +} + +func TestUndeclaredHeaderError(t *testing.T) { + err := UndeclaredHeaderError( + "X-Custom-Header", + "header-value", + []string{"Content-Type", "Authorization"}, + "request", + "/api/endpoint", + "GET", + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeHeader, err.ValidationSubType) + assert.Contains(t, err.Message, "request header 'X-Custom-Header'") + assert.Contains(t, err.Reason, "Content-Type, Authorization") + assert.Contains(t, err.HowToFix, "X-Custom-Header") + assert.Equal(t, "/api/endpoint", err.RequestPath) + assert.Equal(t, "GET", err.RequestMethod) + assert.Equal(t, "X-Custom-Header", err.ParameterName) + assert.Equal(t, "header-value", err.Context) +} + +func TestUndeclaredHeaderError_Response(t *testing.T) { + err := UndeclaredHeaderError( + "X-Response-Header", + "value", + nil, + "response", + "/test", + "POST", + ) + + assert.Contains(t, err.Message, "response header") +} + +func TestUndeclaredHeaderError_EmptyDirection(t *testing.T) { + err := UndeclaredHeaderError( + "X-Header", + "value", + nil, + "", + "/test", + "GET", + ) + + assert.Contains(t, err.Message, "request header") +} + +func TestUndeclaredQueryParamError(t *testing.T) { + err := UndeclaredQueryParamError( + "$.query.debug", + "debug", + "true", + []string{"page", "limit"}, + "/items", + "GET", + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeQuery, err.ValidationSubType) + assert.Contains(t, err.Message, "query parameter 'debug' at '$.query.debug'") + assert.Contains(t, err.Reason, "page, limit") + assert.Contains(t, err.HowToFix, "debug") + assert.Contains(t, err.HowToFix, "$.query.debug") + assert.Equal(t, "/items", err.RequestPath) + assert.Equal(t, "GET", err.RequestMethod) + assert.Equal(t, "debug", err.ParameterName) +} + +func TestUndeclaredCookieError(t *testing.T) { + err := UndeclaredCookieError( + "$.cookies.tracking", + "tracking", + "abc123", + []string{"session", "csrf"}, + "/dashboard", + "GET", + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeCookie, err.ValidationSubType) + assert.Contains(t, err.Message, "cookie 'tracking' at '$.cookies.tracking'") + assert.Contains(t, err.Reason, "session, csrf") + assert.Contains(t, err.HowToFix, "tracking") + assert.Contains(t, err.HowToFix, "$.cookies.tracking") + assert.Equal(t, "/dashboard", err.RequestPath) + assert.Equal(t, "GET", err.RequestMethod) + assert.Equal(t, "tracking", err.ParameterName) +} + +func TestTruncateForContext_String(t *testing.T) { + // Short string should not be truncated + short := truncateForContext("short") + assert.Equal(t, "short", short) + + // Long string should be truncated + long := truncateForContext("this is a very long string that exceeds fifty characters and should be truncated") + assert.Len(t, long, 50) + assert.True(t, len(long) <= 50) + assert.Contains(t, long, "...") +} + +func TestTruncateForContext_Map(t *testing.T) { + m := map[string]any{"key": "value"} + result := truncateForContext(m) + assert.Equal(t, "{...}", result) +} + +func TestTruncateForContext_Slice(t *testing.T) { + s := []any{1, 2, 3} + result := truncateForContext(s) + assert.Equal(t, "[...]", result) +} + +func TestTruncateForContext_Other(t *testing.T) { + // Integer + i := truncateForContext(12345) + assert.Equal(t, "12345", i) + + // Boolean + b := truncateForContext(true) + assert.Equal(t, "true", b) + + // Long formatted value + type customType struct { + Field1 string + Field2 string + Field3 string + } + longValue := customType{ + Field1: "this is a long value", + Field2: "that will exceed fifty", + Field3: "characters when formatted", + } + result := truncateForContext(longValue) + assert.True(t, len(result) <= 50) + assert.Contains(t, result, "...") +} diff --git a/helpers/schema_compiler.go b/helpers/schema_compiler.go index 85c7be6..6218912 100644 --- a/helpers/schema_compiler.go +++ b/helpers/schema_compiler.go @@ -54,20 +54,22 @@ func NewCompiledSchema(name string, jsonSchema []byte, o *config.ValidationOptio // The version parameter determines which OpenAPI keywords are allowed: // - version 3.0: Allows OpenAPI 3.0 keywords like 'nullable' // - version 3.1+: Rejects OpenAPI 3.0 keywords like 'nullable' (strict JSON Schema compliance) -func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, o *config.ValidationOptions, version float32) (*jsonschema.Schema, error) { - compiler := NewCompilerWithOptions(o) +func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, options *config.ValidationOptions, version float32) (*jsonschema.Schema, error) { + compiler := NewCompilerWithOptions(options) compiler.UseLoader(NewCompilerLoader()) // register OpenAPI vocabulary with appropriate version and coercion settings - if o != nil && o.OpenAPIMode { + if options != nil && options.OpenAPIMode { var vocabVersion openapi_vocabulary.VersionType - if version >= 3.05 { // Use 3.05 to avoid floating point precision issues + if version >= 3.15 { // use 3.15 to avoid floating point precision issues (3.2+) + vocabVersion = openapi_vocabulary.Version32 + } else if version >= 3.05 { // use 3.05 to avoid floating point precision issues (3.1) vocabVersion = openapi_vocabulary.Version31 } else { vocabVersion = openapi_vocabulary.Version30 } - vocab := openapi_vocabulary.NewOpenAPIVocabularyWithCoercion(vocabVersion, o.AllowScalarCoercion) + vocab := openapi_vocabulary.NewOpenAPIVocabularyWithCoercion(vocabVersion, options.AllowScalarCoercion) compiler.RegisterVocabulary(vocab) compiler.AssertVocabs() @@ -75,7 +77,7 @@ func NewCompiledSchemaWithVersion(name string, jsonSchema []byte, o *config.Vali jsonSchema = transformOpenAPI30Schema(jsonSchema) } - if o.AllowScalarCoercion { + if options.AllowScalarCoercion { jsonSchema = transformSchemaForCoercion(jsonSchema) } } diff --git a/helpers/schema_compiler_test.go b/helpers/schema_compiler_test.go index c9f6262..0cfc103 100644 --- a/helpers/schema_compiler_test.go +++ b/helpers/schema_compiler_test.go @@ -174,6 +174,38 @@ func TestNewCompiledSchemaWithVersion_OpenAPIMode_Version31(t *testing.T) { require.NotNil(t, jsch, "Should return compiled schema") } +func TestNewCompiledSchemaWithVersion_OpenAPIMode_Version32(t *testing.T) { + schemaJSON := `{ + "type": "string" + }` + + options := config.NewValidationOptions( + config.WithOpenAPIMode(), + ) + + // Test version 3.2 (>= 3.15) + jsch, err := NewCompiledSchemaWithVersion("test", []byte(schemaJSON), options, 3.2) + require.NoError(t, err, "Should compile OpenAPI 3.2 schema") + require.NotNil(t, jsch, "Should return compiled schema") +} + +func TestNewCompiledSchemaWithVersion_OpenAPIMode_Version32_NullableRejected(t *testing.T) { + schemaJSON := `{ + "type": "string", + "nullable": true + }` + + options := config.NewValidationOptions( + config.WithOpenAPIMode(), + ) + + // Test version 3.2 (>= 3.15) with nullable should fail (same as 3.1+) + jsch, err := NewCompiledSchemaWithVersion("test", []byte(schemaJSON), options, 3.2) + assert.Error(t, err, "Should fail for nullable in OpenAPI 3.2") + assert.Nil(t, jsch, "Should not return compiled schema") + assert.Contains(t, err.Error(), "The `nullable` keyword is not supported in OpenAPI 3.1+") +} + func TestNewCompiledSchemaWithVersion_OpenAPIMode_Version31_NullableRejected(t *testing.T) { schemaJSON := `{ "type": "string", diff --git a/openapi_vocabulary/coercion_simple_test.go b/openapi_vocabulary/coercion_simple_test.go index e1cfb7a..bf3a6f0 100644 --- a/openapi_vocabulary/coercion_simple_test.go +++ b/openapi_vocabulary/coercion_simple_test.go @@ -311,6 +311,21 @@ func TestIsCoercibleType_String(t *testing.T) { assert.False(t, IsCoercibleType("array")) } +func TestIsCoercibleType_Array(t *testing.T) { + // Array containing coercible type - should return true + assert.True(t, IsCoercibleType([]any{"string", "boolean"})) + assert.True(t, IsCoercibleType([]any{"number", "null"})) + assert.True(t, IsCoercibleType([]any{"integer"})) + + // Array containing only non-coercible types - should return false + assert.False(t, IsCoercibleType([]any{"string", "null"})) + assert.False(t, IsCoercibleType([]any{"object", "array"})) + assert.False(t, IsCoercibleType([]any{"string"})) + + // Empty array - should return false + assert.False(t, IsCoercibleType([]any{})) +} + func TestCoercionExtension_ShouldCoerceToMethods(t *testing.T) { // Test shouldCoerceToNumber method ext := &coercionExtension{ diff --git a/openapi_vocabulary/nullable.go b/openapi_vocabulary/nullable.go index 1f425c6..bb63c1f 100644 --- a/openapi_vocabulary/nullable.go +++ b/openapi_vocabulary/nullable.go @@ -15,7 +15,7 @@ func CompileNullable(_ *jsonschema.CompilerContext, obj map[string]any, version } // check if nullable is used in OpenAPI 3.1+ (not allowed) - if version == Version31 { + if version == Version31 || version == Version32 { return nil, &OpenAPIKeywordError{ Keyword: "nullable", Message: "The `nullable` keyword is not supported in OpenAPI 3.1+. Use `type: ['string', 'null']` instead", diff --git a/openapi_vocabulary/vocabulary.go b/openapi_vocabulary/vocabulary.go index 59ba14b..452d82a 100644 --- a/openapi_vocabulary/vocabulary.go +++ b/openapi_vocabulary/vocabulary.go @@ -16,8 +16,8 @@ type VersionType int const ( // Version30 represents OpenAPI 3.0.x Version30 VersionType = iota - // Version31 represents OpenAPI 3.1.x (and later) Version31 + Version32 ) // NewOpenAPIVocabulary creates a vocabulary for OpenAPI-specific keywords diff --git a/parameters/cookie_parameters.go b/parameters/cookie_parameters.go index 8f74b9f..1053caa 100644 --- a/parameters/cookie_parameters.go +++ b/parameters/cookie_parameters.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -16,6 +16,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/paths" + "github.com/pb33f/libopenapi-validator/strict" ) func (v *paramValidator) ValidateCookieParams(request *http.Request) (bool, []*errors.ValidationError) { @@ -43,111 +44,135 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, // extract params for the operation params := helpers.ExtractParamsForOperation(request, pathItem) var validationErrors []*errors.ValidationError + + // build a map of cookies from the request for efficient lookup + cookieMap := make(map[string]*http.Cookie) + for _, cookie := range request.Cookies() { + cookieMap[cookie.Name] = cookie + } + for _, p := range params { if p.In == helpers.Cookie { - for _, cookie := range request.Cookies() { - if cookie.Name == p.Name { // cookies are case-sensitive, an exact match is required + // look up the cookie by name (cookies are case-sensitive) + cookie, found := cookieMap[p.Name] + if !found { + // cookie not present in request - check if required + if p.Required != nil && *p.Required { + validationErrors = append(validationErrors, errors.CookieParameterMissing(p)) + } + continue + } - var sch *base.Schema - if p.Schema != nil { - sch = p.Schema.Schema() + var sch *base.Schema + if p.Schema != nil { + sch = p.Schema.Schema() + } + pType := sch.Type + + for _, ty := range pType { + switch ty { + case helpers.Integer: + if _, err := strconv.ParseInt(cookie.Value, 10, 64); err != nil { + validationErrors = append(validationErrors, + errors.InvalidCookieParamInteger(p, strings.ToLower(cookie.Value), sch)) + break } - pType := sch.Type - - for _, ty := range pType { - switch ty { - case helpers.Integer: - if _, err := strconv.ParseInt(cookie.Value, 10, 64); err != nil { - validationErrors = append(validationErrors, - errors.InvalidCookieParamInteger(p, strings.ToLower(cookie.Value), sch)) + // validate value matches allowed enum values + if sch.Enum != nil { + matchFound := false + for _, enumVal := range sch.Enum { + if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { + matchFound = true break } - // check if enum is in range - if sch.Enum != nil { - matchFound := false - for _, enumVal := range sch.Enum { - if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { - matchFound = true - break - } - } - if !matchFound { - validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) - } - } - case helpers.Number: - if _, err := strconv.ParseFloat(cookie.Value, 64); err != nil { - validationErrors = append(validationErrors, - errors.InvalidCookieParamNumber(p, strings.ToLower(cookie.Value), sch)) + } + if !matchFound { + validationErrors = append(validationErrors, + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + } + } + case helpers.Number: + if _, err := strconv.ParseFloat(cookie.Value, 64); err != nil { + validationErrors = append(validationErrors, + errors.InvalidCookieParamNumber(p, strings.ToLower(cookie.Value), sch)) + break + } + // validate value matches allowed enum values + if sch.Enum != nil { + matchFound := false + for _, enumVal := range sch.Enum { + if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { + matchFound = true break } - // check if enum is in range - if sch.Enum != nil { - matchFound := false - for _, enumVal := range sch.Enum { - if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { - matchFound = true - break - } - } - if !matchFound { - validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) - } - } - case helpers.Boolean: - if _, err := strconv.ParseBool(cookie.Value); err != nil { - validationErrors = append(validationErrors, - errors.IncorrectCookieParamBool(p, strings.ToLower(cookie.Value), sch)) - } - case helpers.Object: - if !p.IsExploded() { - encodedObj := helpers.ConstructMapFromCSV(cookie.Value) - - // if a schema was extracted - if sch != nil { - validationErrors = append(validationErrors, - ValidateParameterSchema(sch, encodedObj, "", - "Cookie parameter", - "The cookie parameter", - p.Name, - helpers.ParameterValidation, - helpers.ParameterValidationQuery, - v.options)...) - } - } - case helpers.Array: - - if !p.IsExploded() { - // well we're already in an array, so we need to check the items schema - // to ensure this array items matches the type - // only check if items is a schema, not a boolean - if sch.Items.IsA() { - validationErrors = append(validationErrors, - ValidateCookieArray(sch, p, cookie.Value)...) - } - } + } + if !matchFound { + validationErrors = append(validationErrors, + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + } + } + case helpers.Boolean: + if _, err := strconv.ParseBool(cookie.Value); err != nil { + validationErrors = append(validationErrors, + errors.IncorrectCookieParamBool(p, strings.ToLower(cookie.Value), sch)) + } + case helpers.Object: + if !p.IsExploded() { + encodedObj := helpers.ConstructMapFromCSV(cookie.Value) + + // if a schema was extracted + if sch != nil { + validationErrors = append(validationErrors, + ValidateParameterSchema(sch, encodedObj, "", + "Cookie parameter", + "The cookie parameter", + p.Name, + helpers.ParameterValidation, + helpers.ParameterValidationQuery, + v.options)...) + } + } + case helpers.Array: + + if !p.IsExploded() { + // well we're already in an array, so we need to check the items schema + // to ensure this array items matches the type + // only check if items is a schema, not a boolean + if sch.Items.IsA() { + validationErrors = append(validationErrors, + ValidateCookieArray(sch, p, cookie.Value)...) + } + } + + case helpers.String: - case helpers.String: - - // check if the schema has an enum, and if so, match the value against one of - // the defined enum values. - if sch.Enum != nil { - matchFound := false - for _, enumVal := range sch.Enum { - if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { - matchFound = true - break - } - } - if !matchFound { - validationErrors = append(validationErrors, - errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) - } + // check if the schema has an enum, and if so, match the value against one of + // the defined enum values. + if sch.Enum != nil { + matchFound := false + for _, enumVal := range sch.Enum { + if strings.TrimSpace(cookie.Value) == fmt.Sprint(enumVal.Value) { + matchFound = true + break } } + if !matchFound { + validationErrors = append(validationErrors, + errors.IncorrectCookieParamEnum(p, strings.ToLower(cookie.Value), sch)) + break + } } + validationErrors = append(validationErrors, + ValidateSingleParameterSchema( + sch, + cookie.Value, + "Cookie parameter", + "The cookie parameter", + p.Name, + helpers.ParameterValidation, + helpers.ParameterValidationCookie, + v.options, + )...) } } } @@ -155,6 +180,26 @@ func (v *paramValidator) ValidateCookieParamsWithPathItem(request *http.Request, errors.PopulateValidationErrors(validationErrors, request, pathValue) + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared cookies + if v.options.StrictMode { + undeclaredCookies := strict.ValidateCookies(request, params, v.options) + for _, undeclared := range undeclaredCookies { + validationErrors = append(validationErrors, + errors.UndeclaredCookieError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + request.URL.Path, + request.Method, + )) + } + } + if len(validationErrors) > 0 { return false, validationErrors } diff --git a/parameters/cookie_parameters_test.go b/parameters/cookie_parameters_test.go index 8014bdd..b54be1e 100644 --- a/parameters/cookie_parameters_test.go +++ b/parameters/cookie_parameters_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -10,7 +10,10 @@ import ( "github.com/pb33f/libopenapi" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/pb33f/libopenapi-validator/config" + "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/paths" ) @@ -729,3 +732,828 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "GET Path '/pizza/beef' not found", errors[0].Message) } + +// Tests for required cookie validation (GitHub issue #183) + +func TestNewValidator_CookieRequiredMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // No cookie added - this should fail validation + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'PattyPreference' is missing", errors[0].Message) + assert.Equal(t, "The cookie parameter 'PattyPreference' is defined as being required, "+ + "however it's missing from the request", errors[0].Reason) + assert.Equal(t, helpers.ParameterValidation, errors[0].ValidationType) + assert.Equal(t, helpers.ParameterValidationCookie, errors[0].ValidationSubType) +} + +func TestNewValidator_CookieOptionalMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: false + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // No cookie added - this should pass validation since it's optional + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieOptionalMissingNoRequiredField(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // No cookie added - this should pass validation since required defaults to false + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieMultipleRequiredOneMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number + - name: BunType + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // Only add one cookie + request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "1.5"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'BunType' is missing", errors[0].Message) +} + +func TestNewValidator_CookieMultipleRequiredBothMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number + - name: BunType + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // No cookies added + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 2) +} + +func TestNewValidator_CookieMultipleRequiredAllPresent(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number + - name: BunType + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "1.5"}) + request.AddCookie(&http.Cookie{Name: "BunType", Value: "sesame"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieCaseSensitive(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // Add cookie with different case - should not match + request.AddCookie(&http.Cookie{Name: "pattypreference", Value: "1.5"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'PattyPreference' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredWithInvalidValue(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "not-a-number"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + // Should be a type error, not a missing error + assert.Contains(t, errors[0].Message, "not a valid number") +} + +func TestNewValidator_CookieMixedRequiredOptional(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number + - name: ExtraCheese + in: cookie + required: false + schema: + type: boolean` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // Only add the required cookie + request.AddCookie(&http.Cookie{Name: "PattyPreference", Value: "2.5"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieRequiredIntegerMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyCount + in: cookie + required: true + schema: + type: integer` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'PattyCount' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredBooleanMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: ExtraCheese + in: cookie + required: true + schema: + type: boolean` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'ExtraCheese' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredStringMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: CustomerName + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'CustomerName' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredArrayMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Toppings + in: cookie + required: true + schema: + type: array + items: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'Toppings' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredObjectMissing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Preferences + in: cookie + required: true + explode: false + schema: + type: object + properties: + pink: + type: boolean + number: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'Preferences' is missing", errors[0].Message) +} + +func TestNewValidator_CookieRequiredWithPathItem(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: PattyPreference + in: cookie + required: true + schema: + type: number` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + // No cookie added + + // Use the WithPathItem variant + path, _, pv := paths.FindPath(request, &m.Model, &sync.Map{}) + + valid, errors := v.ValidateCookieParamsWithPathItem(request, path, pv) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'PattyPreference' is missing", errors[0].Message) +} + +// Tests for string schema validation (GitHub issue #184) + +func TestNewValidator_CookieParamStringValidPattern(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: SessionID + in: cookie + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "SessionID", Value: "550e8400-e29b-41d4-a716-446655440000"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidPattern(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: SessionID + in: cookie + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "SessionID", Value: "invalid_value"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "does not match pattern") +} + +func TestNewValidator_CookieParamStringValidFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: SessionID + in: cookie + required: true + schema: + type: string + format: uuid` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "SessionID", Value: "550e8400-e29b-41d4-a716-446655440000"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: SessionID + in: cookie + required: true + schema: + type: string + format: uuid` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "SessionID", Value: "not-a-valid-uuid"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "uuid") +} + +func TestNewValidator_CookieParamStringValidMinLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Token + in: cookie + required: true + schema: + type: string + minLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Token", Value: "abcdefghij"}) // exactly 10 chars + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidMinLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Token + in: cookie + required: true + schema: + type: string + minLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Token", Value: "short"}) // only 5 chars + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "minLength") +} + +func TestNewValidator_CookieParamStringValidMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Token + in: cookie + required: true + schema: + type: string + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Token", Value: "short"}) // 5 chars + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Token + in: cookie + required: true + schema: + type: string + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Token", Value: "this-is-way-too-long"}) // 20 chars + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "maxLength") +} + +func TestNewValidator_CookieParamStringValidPatternAndMinMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Code + in: cookie + required: true + schema: + type: string + pattern: '^[A-Z]+$' + minLength: 3 + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Code", Value: "ABCDEF"}) // 6 chars, all uppercase + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidPatternButValidLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: Code + in: cookie + required: true + schema: + type: string + pattern: '^[A-Z]+$' + minLength: 3 + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "Code", Value: "abcdef"}) // 6 chars, but lowercase - fails pattern + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "does not match pattern") +} + +func TestNewValidator_CookieParamStringEmailFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: UserEmail + in: cookie + required: true + schema: + type: string + format: email` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "UserEmail", Value: "user@example.com"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_CookieParamStringInvalidEmailFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: UserEmail + in: cookie + required: true + schema: + type: string + format: email` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "UserEmail", Value: "not-an-email"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "email") +} + +func TestNewValidator_CookieParamMissingRequired(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: session_id + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Create request WITHOUT the required cookie + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Equal(t, "Cookie parameter 'session_id' is missing", errors[0].Message) + assert.Contains(t, errors[0].Reason, "required") +} + +func TestNewValidator_CookieParams_StrictMode_UndeclaredCookie(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: session_id + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "session_id", Value: "abc123"}) + request.AddCookie(&http.Cookie{Name: "extra_cookie", Value: "undeclared"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.False(t, valid) + require.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "extra_cookie") + assert.Contains(t, errors[0].Message, "not declared") +} + +func TestNewValidator_CookieParams_StrictMode_ValidRequest(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: session_id + in: cookie + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.AddCookie(&http.Cookie{Name: "session_id", Value: "abc123"}) + + valid, errors := v.ValidateCookieParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} diff --git a/parameters/header_parameters.go b/parameters/header_parameters.go index fed3fc8..6fe8a7f 100644 --- a/parameters/header_parameters.go +++ b/parameters/header_parameters.go @@ -17,6 +17,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/paths" + "github.com/pb33f/libopenapi-validator/strict" ) func (v *paramValidator) ValidateHeaderParams(request *http.Request) (bool, []*errors.ValidationError) { @@ -163,8 +164,20 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, if !matchFound { validationErrors = append(validationErrors, errors.IncorrectHeaderParamEnum(p, strings.ToLower(param), sch)) + break } } + validationErrors = append(validationErrors, + ValidateSingleParameterSchema( + sch, + param, + "Header parameter", + "The header parameter", + p.Name, + helpers.ParameterValidation, + helpers.ParameterValidationHeader, + v.options, + )...) } } if len(pType) == 0 { @@ -184,6 +197,26 @@ func (v *paramValidator) ValidateHeaderParamsWithPathItem(request *http.Request, errors.PopulateValidationErrors(validationErrors, request, pathValue) + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared headers + if v.options.StrictMode { + undeclaredHeaders := strict.ValidateRequestHeaders(request.Header, params, v.options) + for _, undeclared := range undeclaredHeaders { + validationErrors = append(validationErrors, + errors.UndeclaredHeaderError( + undeclared.Name, + undeclared.Value.(string), + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + )) + } + } + if len(validationErrors) > 0 { return false, validationErrors } diff --git a/parameters/header_parameters_test.go b/parameters/header_parameters_test.go index 6c23967..734d658 100644 --- a/parameters/header_parameters_test.go +++ b/parameters/header_parameters_test.go @@ -11,6 +11,7 @@ import ( "github.com/pb33f/libopenapi" "github.com/stretchr/testify/assert" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/paths" ) @@ -757,3 +758,404 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, "GET Path '/buying/drinks' not found", errors[0].Message) } + +func TestNewValidator_HeaderParamStringValidPattern(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-ID + in: header + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-ID", "550e8400-e29b-41d4-a716-446655440000") + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidPattern(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-ID + in: header + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-ID", "invalid_value") + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "does not match pattern") +} + +func TestNewValidator_HeaderParamStringValidFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-ID + in: header + required: true + schema: + type: string + format: uuid` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-ID", "550e8400-e29b-41d4-a716-446655440000") + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-ID + in: header + required: true + schema: + type: string + format: uuid` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-ID", "not-a-valid-uuid") + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "uuid") +} + +func TestNewValidator_HeaderParamStringValidMinLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Token + in: header + required: true + schema: + type: string + minLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Token", "abcdefghij") // exactly 10 chars + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidMinLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Token + in: header + required: true + schema: + type: string + minLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Token", "short") // only 5 chars + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "minLength") +} + +func TestNewValidator_HeaderParamStringValidMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Token + in: header + required: true + schema: + type: string + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Token", "short") // 5 chars + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Token + in: header + required: true + schema: + type: string + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Token", "this-is-way-too-long") // 20 chars + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "maxLength") +} + +func TestNewValidator_HeaderParamStringValidPatternAndMinMaxLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Code + in: header + required: true + schema: + type: string + pattern: '^[A-Z]+$' + minLength: 3 + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Code", "ABCDEF") // 6 chars, all uppercase + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidPatternButValidLength(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Code + in: header + required: true + schema: + type: string + pattern: '^[A-Z]+$' + minLength: 3 + maxLength: 10` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Code", "abcdef") // 6 chars, but lowercase - fails pattern + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "does not match pattern") +} + +func TestNewValidator_HeaderParamStringValidEnumAndPattern(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Status + in: header + required: true + schema: + type: string + enum: [ACTIVE, INACTIVE, PENDING]` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Status", "ACTIVE") + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringEmailFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-User-Email + in: header + required: true + schema: + type: string + format: email` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-User-Email", "user@example.com") + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} + +func TestNewValidator_HeaderParamStringInvalidEmailFormat(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-User-Email + in: header + required: true + schema: + type: string + format: email` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithFormatAssertions()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-User-Email", "not-an-email") + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].SchemaValidationErrors[0].Reason, "email") +} + +func TestNewValidator_HeaderParams_StrictMode_UndeclaredHeader(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-Id + in: header + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-Id", "abc123") + request.Header.Set("X-Undeclared-Header", "should fail") + + valid, errors := v.ValidateHeaderParams(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "X-Undeclared-Header") + assert.Contains(t, errors[0].Message, "not declared") +} + +func TestNewValidator_HeaderParams_StrictMode_ValidRequest(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/beef: + get: + parameters: + - name: X-Request-Id + in: header + required: true + schema: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/beef", nil) + request.Header.Set("X-Request-Id", "abc123") + + valid, errors := v.ValidateHeaderParams(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} diff --git a/parameters/query_parameters.go b/parameters/query_parameters.go index 888cbc8..10111ac 100644 --- a/parameters/query_parameters.go +++ b/parameters/query_parameters.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -19,6 +19,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/paths" + "github.com/pb33f/libopenapi-validator/strict" ) const rx = `[:\/\?#\[\]\@!\$&'\(\)\*\+,;=]` @@ -52,9 +53,24 @@ func (v *paramValidator) ValidateQueryParamsWithPathItem(request *http.Request, queryParams := make(map[string][]*helpers.QueryParam) var validationErrors []*errors.ValidationError + // build a set of spec parameter names for exact matching + specParamNames := make(map[string]bool) + for _, p := range params { + if p.In == helpers.Query { + specParamNames[p.Name] = true + } + } + for qKey, qVal := range request.URL.Query() { - // check if the param is encoded as a property / deepObject - if strings.IndexRune(qKey, '[') > 0 && strings.IndexRune(qKey, ']') > 0 { + // check if the query key exactly matches a spec parameter name (e.g., "match[]") + // if so, store it literally without deepObject stripping + if specParamNames[qKey] { + queryParams[qKey] = append(queryParams[qKey], &helpers.QueryParam{ + Key: qKey, + Values: qVal, + }) + } else if strings.IndexRune(qKey, '[') > 0 && strings.IndexRune(qKey, ']') > 0 { + // check if the param is encoded as a property / deepObject stripped := qKey[:strings.IndexRune(qKey, '[')] value := qKey[strings.IndexRune(qKey, '[')+1 : strings.IndexRune(qKey, ']')] queryParams[stripped] = append(queryParams[stripped], &helpers.QueryParam{ @@ -233,6 +249,26 @@ doneLooking: errors.PopulateValidationErrors(validationErrors, request, pathValue) + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared query parameters + if v.options.StrictMode { + undeclaredParams := strict.ValidateQueryParams(request, params, v.options) + for _, undeclared := range undeclaredParams { + validationErrors = append(validationErrors, + errors.UndeclaredQueryParamError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + request.URL.Path, + request.Method, + )) + } + } + if len(validationErrors) > 0 { return false, validationErrors } diff --git a/parameters/query_parameters_test.go b/parameters/query_parameters_test.go index 137969c..9313bba 100644 --- a/parameters/query_parameters_test.go +++ b/parameters/query_parameters_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -3632,3 +3632,139 @@ paths: assert.Len(t, errors, 1) assert.Equal(t, errors[0].Reason, "The query parameter (which is an array) 'id' contains the following duplicates: 'cake, meat'") } + +func TestNewValidator_QueryParamWithBracketsInName(t *testing.T) { + // Test for issue #210: parameter names with brackets (e.g., match[]) + // should be recognized when URL-encoded as match%5B%5D + // https://github.com/pb33f/libopenapi-validator/issues/210 + spec := `openapi: 3.1.0 +paths: + /api/query: + get: + parameters: + - name: "match[]" + in: query + required: true + explode: false + schema: + type: array + items: + type: string + - name: start + in: query + schema: + type: integer + - name: end + in: query + schema: + type: integer + operationId: queryData +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // URL with encoded brackets: match%5B%5D=up (decodes to match[]=up) + request, _ := http.NewRequest(http.MethodGet, + "https://example.com/api/query?match%5B%5D=up&start=0&end=100", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.True(t, valid, "Expected validation to pass, got errors: %v", errors) + assert.Empty(t, errors) +} + +func TestNewValidator_QueryParamWithBracketsInName_Missing(t *testing.T) { + // Test that missing bracket parameters are still reported correctly + spec := `openapi: 3.1.0 +paths: + /api/query: + get: + parameters: + - name: "match[]" + in: query + required: true + schema: + type: array + items: + type: string + operationId: queryData +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request without the required match[] parameter + request, _ := http.NewRequest(http.MethodGet, + "https://example.com/api/query?other=value", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Equal(t, "Query parameter 'match[]' is missing", errors[0].Message) +} + +func TestNewValidator_QueryParams_StrictMode_UndeclaredParam(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /api/search: + get: + parameters: + - name: query + in: query + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + operationId: search +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + // Request with undeclared 'extra' parameter + request, _ := http.NewRequest(http.MethodGet, + "https://example.com/api/search?query=test&limit=10&extra=undeclared", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "extra") + assert.Contains(t, errors[0].Message, "not declared") +} + +func TestNewValidator_QueryParams_StrictMode_ValidRequest(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /api/search: + get: + parameters: + - name: query + in: query + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + operationId: search +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model, config.WithStrictMode()) + + // Request with only declared parameters + request, _ := http.NewRequest(http.MethodGet, + "https://example.com/api/search?query=test&limit=10", nil) + + valid, errors := v.ValidateQueryParams(request) + assert.True(t, valid) + assert.Len(t, errors, 0) +} diff --git a/parameters/validate_parameter.go b/parameters/validate_parameter.go index f31ffd1..041974e 100644 --- a/parameters/validate_parameter.go +++ b/parameters/validate_parameter.go @@ -9,7 +9,6 @@ import ( "net/url" "reflect" "strings" - "sync" "github.com/pb33f/libopenapi/datamodel/high/base" "github.com/pb33f/libopenapi/utils" @@ -264,24 +263,17 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val } } } - processPoly := func(schemas []*base.SchemaProxy, wg *sync.WaitGroup) { - if len(schemas) > 0 { - for _, s := range schemas { - extractTypes(s) - } + processPoly := func(schemas []*base.SchemaProxy) { + for _, s := range schemas { + extractTypes(s) } - wg.Done() } // check if there is polymorphism going on here. if len(schema.AnyOf) > 0 || len(schema.AllOf) > 0 || len(schema.OneOf) > 0 { - - wg := sync.WaitGroup{} - wg.Add(3) - go processPoly(schema.AnyOf, &wg) - go processPoly(schema.AllOf, &wg) - go processPoly(schema.OneOf, &wg) - wg.Wait() + processPoly(schema.AnyOf) + processPoly(schema.AllOf) + processPoly(schema.OneOf) sep := "or" if len(schema.AllOf) > 0 { diff --git a/parameters/validate_security.go b/parameters/validate_security.go index 8135f74..084f042 100644 --- a/parameters/validate_security.go +++ b/parameters/validate_security.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -8,9 +8,9 @@ import ( "net/http" "strings" - "github.com/pb33f/libopenapi/orderedmap" - + "github.com/pb33f/libopenapi/datamodel/high/base" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" @@ -49,13 +49,18 @@ func (v *paramValidator) ValidateSecurityWithPathItem(request *http.Request, pat return true, nil } - allErrors := []*errors.ValidationError{} + var allErrors []*errors.ValidationError + // each security requirement in the array is OR'd - any one passing is sufficient for _, sec := range security { if sec.ContainsEmptyRequirement { return true, nil } + // within a requirement, all schemes are AND'd - all must pass + requirementSatisfied := true + var requirementErrors []*errors.ValidationError + for pair := orderedmap.First(sec.Requirements); pair != nil; pair = pair.Next() { secName := pair.Key() @@ -73,115 +78,145 @@ func (v *paramValidator) ValidateSecurityWithPathItem(request *http.Request, pat }, } errors.PopulateValidationErrors(validationErrors, request, pathValue) - - return false, validationErrors + requirementSatisfied = false + requirementErrors = append(requirementErrors, validationErrors...) + continue } - secScheme := v.document.Components.SecuritySchemes.GetOrZero(secName) - switch strings.ToLower(secScheme.Type) { - case "http": - switch strings.ToLower(secScheme.Scheme) { - case "basic", "bearer", "digest": - // check for an authorization header - if request.Header.Get("Authorization") == "" { - validationErrors := []*errors.ValidationError{ - { - Message: fmt.Sprintf("Authorization header for '%s' scheme", secScheme.Scheme), - Reason: "Authorization header was not found", - ValidationType: "security", - ValidationSubType: secScheme.Scheme, - SpecLine: sec.GoLow().Requirements.ValueNode.Line, - SpecCol: sec.GoLow().Requirements.ValueNode.Column, - HowToFix: "Add an 'Authorization' header to this request", - }, - } - - errors.PopulateValidationErrors(validationErrors, request, pathValue) - allErrors = append(allErrors, validationErrors...) - } else { - return true, nil - } - } - case "apikey": - // check if the api key is in the request - if secScheme.In == "header" { - if request.Header.Get(secScheme.Name) == "" { - validationErrors := []*errors.ValidationError{ - { - Message: fmt.Sprintf("API Key %s not found in header", secScheme.Name), - Reason: "API Key not found in http header for security scheme 'apiKey' with type 'header'", - ValidationType: "security", - ValidationSubType: "apiKey", - SpecLine: sec.GoLow().Requirements.ValueNode.Line, - SpecCol: sec.GoLow().Requirements.ValueNode.Column, - HowToFix: fmt.Sprintf("Add the API Key via '%s' as a header of the request", secScheme.Name), - }, - } - - errors.PopulateValidationErrors(validationErrors, request, pathValue) - allErrors = append(allErrors, validationErrors...) - } else { - return true, nil - } - } - if secScheme.In == "query" { - if request.URL.Query().Get(secScheme.Name) == "" { - copyUrl := *request.URL - fixed := ©Url - q := fixed.Query() - q.Add(secScheme.Name, "your-api-key") - fixed.RawQuery = q.Encode() - - validationErrors := []*errors.ValidationError{ - { - Message: fmt.Sprintf("API Key %s not found in query", secScheme.Name), - Reason: "API Key not found in URL query for security scheme 'apiKey' with type 'query'", - ValidationType: "security", - ValidationSubType: "apiKey", - SpecLine: sec.GoLow().Requirements.ValueNode.Line, - SpecCol: sec.GoLow().Requirements.ValueNode.Column, - HowToFix: fmt.Sprintf("Add an API Key via '%s' to the query string "+ - "of the URL, for example '%s'", secScheme.Name, fixed.String()), - }, - } - - errors.PopulateValidationErrors(validationErrors, request, pathValue) - allErrors = append(allErrors, validationErrors...) - } else { - return true, nil - } - } - if secScheme.In == "cookie" { - cookies := request.Cookies() - cookieFound := false - for _, cookie := range cookies { - if cookie.Name == secScheme.Name { - cookieFound = true - break - } - } - if !cookieFound { - validationErrors := []*errors.ValidationError{ - { - Message: fmt.Sprintf("API Key %s not found in cookies", secScheme.Name), - Reason: "API Key not found in http request cookies for security scheme 'apiKey' with type 'cookie'", - ValidationType: "security", - ValidationSubType: "apiKey", - SpecLine: sec.GoLow().Requirements.ValueNode.Line, - SpecCol: sec.GoLow().Requirements.ValueNode.Column, - HowToFix: fmt.Sprintf("Submit an API Key '%s' as a cookie with the request", secScheme.Name), - }, - } - - errors.PopulateValidationErrors(validationErrors, request, pathValue) - allErrors = append(allErrors, validationErrors...) - } else { - return true, nil - } - } + secScheme := v.document.Components.SecuritySchemes.GetOrZero(secName) + schemeValid, schemeErrors := v.validateSecurityScheme(secScheme, sec, request, pathValue) + if !schemeValid { + requirementSatisfied = false + requirementErrors = append(requirementErrors, schemeErrors...) } } + + // if all schemes in this requirement passed (AND), the overall security passes (OR) + if requirementSatisfied { + return true, nil + } + allErrors = append(allErrors, requirementErrors...) } return false, allErrors } + +// validateSecurityScheme checks if a single security scheme is satisfied by the request. +func (v *paramValidator) validateSecurityScheme( + secScheme *v3.SecurityScheme, + sec *base.SecurityRequirement, + request *http.Request, + pathValue string, +) (bool, []*errors.ValidationError) { + switch strings.ToLower(secScheme.Type) { + case "http": + return v.validateHTTPSecurityScheme(secScheme, sec, request, pathValue) + case "apikey": + return v.validateAPIKeySecurityScheme(secScheme, sec, request, pathValue) + } + // unknown scheme type - consider it valid to avoid false negatives + return true, nil +} + +func (v *paramValidator) validateHTTPSecurityScheme( + secScheme *v3.SecurityScheme, + sec *base.SecurityRequirement, + request *http.Request, + pathValue string, +) (bool, []*errors.ValidationError) { + switch strings.ToLower(secScheme.Scheme) { + case "basic", "bearer", "digest": + if request.Header.Get("Authorization") == "" { + validationErrors := []*errors.ValidationError{ + { + Message: fmt.Sprintf("Authorization header for '%s' scheme", secScheme.Scheme), + Reason: "Authorization header was not found", + ValidationType: "security", + ValidationSubType: secScheme.Scheme, + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: "Add an 'Authorization' header to this request", + }, + } + errors.PopulateValidationErrors(validationErrors, request, pathValue) + return false, validationErrors + } + return true, nil + } + return true, nil +} + +func (v *paramValidator) validateAPIKeySecurityScheme( + secScheme *v3.SecurityScheme, + sec *base.SecurityRequirement, + request *http.Request, + pathValue string, +) (bool, []*errors.ValidationError) { + switch secScheme.In { + case "header": + if request.Header.Get(secScheme.Name) == "" { + validationErrors := []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in header", secScheme.Name), + Reason: "API Key not found in http header for security scheme 'apiKey' with type 'header'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Add the API Key via '%s' as a header of the request", secScheme.Name), + }, + } + errors.PopulateValidationErrors(validationErrors, request, pathValue) + return false, validationErrors + } + return true, nil + + case "query": + if request.URL.Query().Get(secScheme.Name) == "" { + copyUrl := *request.URL + fixed := ©Url + q := fixed.Query() + q.Add(secScheme.Name, "your-api-key") + fixed.RawQuery = q.Encode() + + validationErrors := []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in query", secScheme.Name), + Reason: "API Key not found in URL query for security scheme 'apiKey' with type 'query'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Add an API Key via '%s' to the query string "+ + "of the URL, for example '%s'", secScheme.Name, fixed.String()), + }, + } + errors.PopulateValidationErrors(validationErrors, request, pathValue) + return false, validationErrors + } + return true, nil + + case "cookie": + cookies := request.Cookies() + for _, cookie := range cookies { + if cookie.Name == secScheme.Name { + return true, nil + } + } + validationErrors := []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in cookies", secScheme.Name), + Reason: "API Key not found in http request cookies for security scheme 'apiKey' with type 'cookie'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Submit an API Key '%s' as a cookie with the request", secScheme.Name), + }, + } + errors.PopulateValidationErrors(validationErrors, request, pathValue) + return false, validationErrors + } + + return true, nil +} diff --git a/parameters/validate_security_test.go b/parameters/validate_security_test.go index 3957613..8b90212 100644 --- a/parameters/validate_security_test.go +++ b/parameters/validate_security_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package parameters @@ -717,3 +717,339 @@ components: assert.True(t, valid) assert.Equal(t, 0, len(errors)) } + +func TestParamValidator_ValidateSecurity_ANDRequirement_BothPresent(t *testing.T) { + // Test AND security requirement: both schemes in same requirement must pass + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + BasicAuth: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with BOTH api key AND authorization header - should pass + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("X-API-Key", "1234") + request.Header.Add("Authorization", "Basic dXNlcjpwYXNz") + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestParamValidator_ValidateSecurity_ANDRequirement_OnlyApiKey(t *testing.T) { + // Test AND security requirement: missing one scheme should fail + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + BasicAuth: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with ONLY api key - should fail because BasicAuth is also required + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("X-API-Key", "1234") + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "Authorization header") +} + +func TestParamValidator_ValidateSecurity_ANDRequirement_OnlyBasicAuth(t *testing.T) { + // Test AND security requirement: missing one scheme should fail + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + BasicAuth: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with ONLY authorization header - should fail because ApiKeyAuthHeader is also required + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("Authorization", "Basic dXNlcjpwYXNz") + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "API Key") +} + +func TestParamValidator_ValidateSecurity_ANDRequirement_NeitherPresent(t *testing.T) { + // Test AND security requirement: missing both schemes should return errors for both + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + BasicAuth: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with neither - should fail with errors for both + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Len(t, errors, 2) +} + +func TestParamValidator_ValidateSecurity_ORWithAND_FirstOROptionPasses(t *testing.T) { + // Test mixed OR and AND: first option is single scheme, second is AND requirement + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + - BasicAuth: [] + BearerAuth: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + BasicAuth: + type: http + scheme: basic + BearerAuth: + type: http + scheme: bearer +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with only API key - should pass (first OR option) + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("X-API-Key", "1234") + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestParamValidator_ValidateSecurity_ORWithAND_SecondOROptionPasses(t *testing.T) { + // Test mixed OR and AND: second option (AND requirement) passes + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + - BasicAuth: [] + ApiKeyAuthQuery: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + ApiKeyAuthQuery: + type: apiKey + in: query + name: api_key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with basic auth AND query API key - should pass (second OR option, which is AND) + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products?api_key=secret", nil) + request.Header.Add("Authorization", "Basic dXNlcjpwYXNz") + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestParamValidator_ValidateSecurity_ORWithAND_PartialSecondOption(t *testing.T) { + // Test mixed OR and AND: partial match on second option should try both and fail + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuthHeader: [] + - BasicAuth: [] + ApiKeyAuthQuery: [] +components: + securitySchemes: + ApiKeyAuthHeader: + type: apiKey + in: header + name: X-API-Key + ApiKeyAuthQuery: + type: apiKey + in: query + name: api_key + BasicAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with only basic auth - should fail (first option needs X-API-Key header, + // second option needs BOTH basic auth AND api_key query param) + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("Authorization", "Basic dXNlcjpwYXNz") + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + // Should have errors from both OR options + assert.GreaterOrEqual(t, len(errors), 1) +} + +func TestParamValidator_ValidateSecurity_UnknownSchemeType(t *testing.T) { + // Test oauth2 type - unknown to our validator, should pass through (not fail) + spec := `openapi: 3.1.0 +paths: + /products: + get: + security: + - OAuth2: [] +components: + securitySchemes: + OAuth2: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/oauth + scopes: + read: Read access +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with no auth - should pass because oauth2 type is not validated + request, _ := http.NewRequest(http.MethodGet, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestParamValidator_ValidateSecurity_UnknownHTTPScheme(t *testing.T) { + // Test custom HTTP scheme - unknown to our validator, should pass through (not fail) + spec := `openapi: 3.1.0 +paths: + /products: + get: + security: + - CustomAuth: [] +components: + securitySchemes: + CustomAuth: + type: http + scheme: custom +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with no auth - should pass because custom scheme is not validated + request, _ := http.NewRequest(http.MethodGet, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} + +func TestParamValidator_ValidateSecurity_APIKey_UnknownInLocation(t *testing.T) { + // Test apiKey with unknown "in" location - should pass through (fallback at line 221) + spec := `openapi: 3.1.0 +paths: + /products: + get: + security: + - ApiKeyAuth: [] +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: body + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + v := NewParameterValidator(&m.Model) + + // Request with no auth - should pass because "body" is an unknown "in" location + // and the validator falls through to return true (line 221) + request, _ := http.NewRequest(http.MethodGet, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Empty(t, errors) +} diff --git a/paths/paths.go b/paths/paths.go index 0db194b..177f1de 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -25,6 +25,9 @@ import ( // that were picked up when locating the path. // The third return value will be the path that was found in the document, as it pertains to the contract, so all path // parameters will not have been replaced with their values from the request - allowing model lookups. +// +// Path matching follows the OpenAPI specification: literal (concrete) paths take precedence over +// parameterized paths, regardless of definition order in the specification. func FindPath(request *http.Request, document *v3.Document, regexCache config.RegexCache) (*v3.PathItem, []*errors.ValidationError, string) { basePaths := getBasePaths(document) stripped := StripRequestPath(request, document) @@ -34,21 +37,15 @@ func FindPath(request *http.Request, document *v3.Document, regexCache config.Re reqPathSegments = reqPathSegments[1:] } - var pItem *v3.PathItem - var foundPath string + candidates := make([]pathCandidate, 0, document.Paths.PathItems.Len()) + for pair := orderedmap.First(document.Paths.PathItems); pair != nil; pair = pair.Next() { path := pair.Key() pathItem := pair.Value() - // if the stripped path has a fragment, then use that as part of the lookup - // if not, then strip off any fragments from the pathItem - if !strings.Contains(stripped, "#") { - if strings.Contains(path, "#") { - path = strings.Split(path, "#")[0] - } - } + pathForMatching := normalizePathForMatching(path, stripped) - segs := strings.Split(path, "/") + segs := strings.Split(pathForMatching, "/") if segs[0] == "" { segs = segs[1:] } @@ -57,72 +54,68 @@ func FindPath(request *http.Request, document *v3.Document, regexCache config.Re if !ok { continue } - pItem = pathItem - foundPath = path - switch request.Method { - case http.MethodGet: - if pathItem.Get != nil { - return pathItem, nil, path - } - case http.MethodPost: - if pathItem.Post != nil { - return pathItem, nil, path - } - case http.MethodPut: - if pathItem.Put != nil { - return pathItem, nil, path - } - case http.MethodDelete: - if pathItem.Delete != nil { - return pathItem, nil, path - } - case http.MethodOptions: - if pathItem.Options != nil { - return pathItem, nil, path - } - case http.MethodHead: - if pathItem.Head != nil { - return pathItem, nil, path - } - case http.MethodPatch: - if pathItem.Patch != nil { - return pathItem, nil, path - } - case http.MethodTrace: - if pathItem.Trace != nil { - return pathItem, nil, path - } + + // Compute specificity score and check if method exists + score := computeSpecificityScore(path) + hasMethod := pathHasMethod(pathItem, request.Method) + + candidates = append(candidates, pathCandidate{ + pathItem: pathItem, + path: path, + score: score, + hasMethod: hasMethod, + }) + } + + if len(candidates) == 0 { + validationErrors := []*errors.ValidationError{ + { + ValidationType: helpers.ParameterValidationPath, + ValidationSubType: "missing", + Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path), + Reason: fmt.Sprintf("The %s request contains a path of '%s' "+ + "however that path, or the %s method for that path does not exist in the specification", + request.Method, request.URL.Path, request.Method), + SpecLine: -1, + SpecCol: -1, + HowToFix: errors.HowToFixPath, + }, } + errors.PopulateValidationErrors(validationErrors, request, "") + return nil, validationErrors, "" } - if pItem != nil { - validationErrors := []*errors.ValidationError{{ - ValidationType: helpers.ParameterValidationPath, - ValidationSubType: "missingOperation", - Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path), - Reason: fmt.Sprintf("The %s method for that path does not exist in the specification", - request.Method), - SpecLine: -1, - SpecCol: -1, - HowToFix: errors.HowToFixPath, - }} - errors.PopulateValidationErrors(validationErrors, request, foundPath) - return pItem, validationErrors, foundPath + + bestWithMethod, bestOverall := selectMatches(candidates) + + if bestWithMethod != nil { + return bestWithMethod.pathItem, nil, bestWithMethod.path } - validationErrors := []*errors.ValidationError{ - { - ValidationType: helpers.ParameterValidationPath, - ValidationSubType: "missing", - Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path), - Reason: fmt.Sprintf("The %s request contains a path of '%s' "+ - "however that path, or the %s method for that path does not exist in the specification", - request.Method, request.URL.Path, request.Method), - SpecLine: -1, - SpecCol: -1, - HowToFix: errors.HowToFixPath, - }, + + // path matches exist but none have the required method + validationErrors := []*errors.ValidationError{{ + ValidationType: helpers.ParameterValidationPath, + ValidationSubType: "missingOperation", + Message: fmt.Sprintf("%s Path '%s' not found", request.Method, request.URL.Path), + Reason: fmt.Sprintf("The %s method for that path does not exist in the specification", + request.Method), + SpecLine: -1, + SpecCol: -1, + HowToFix: errors.HowToFixPath, + }} + errors.PopulateValidationErrors(validationErrors, request, bestOverall.path) + return bestOverall.pathItem, validationErrors, bestOverall.path +} + +// normalizePathForMatching removes the fragment from a path template unless +// the request path itself contains a fragment. +func normalizePathForMatching(path, requestPath string) string { + if strings.Contains(requestPath, "#") { + return path } - errors.PopulateValidationErrors(validationErrors, request, "") - return nil, validationErrors, "" + if idx := strings.IndexByte(path, '#'); idx >= 0 { + return path[:idx] + } + return path } func getBasePaths(document *v3.Document) []string { diff --git a/paths/paths_test.go b/paths/paths_test.go index d7650c8..32f5f75 100644 --- a/paths/paths_test.go +++ b/paths/paths_test.go @@ -859,3 +859,505 @@ paths: assert.Len(t, keys, 4) assert.Len(t, addresses, 3) } + +// Test cases for path precedence - Issue #181 +// According to OpenAPI spec, literal paths take precedence over parameterized paths + +func TestFindPath_LiteralTakesPrecedenceOverParameter(t *testing.T) { + // This is the exact bug case from issue #181 + spec := `openapi: 3.1.0 +info: + title: Path Precedence Bug + version: 1.0.0 +paths: + /Messages/{message_id}: + parameters: + - name: message_id + in: path + required: true + schema: + type: string + pattern: '^comms_message_[0-7][a-hjkmnpqrstv-z0-9]{25,34}' + get: + operationId: getMessage + responses: + '200': + description: OK + /Messages/Operations: + get: + operationId: getOperations + summary: List Operations + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request to literal path should match literal, not parameter + request, _ := http.NewRequest(http.MethodGet, "https://api.com/Messages/Operations", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs, "Expected no errors") + assert.NotNil(t, pathItem, "Expected pathItem to be found") + assert.Equal(t, "getOperations", pathItem.Get.OperationId, "Should match literal path") + assert.Equal(t, "/Messages/Operations", foundPath) +} + +func TestFindPath_LiteralPrecedence_ReverseOrder(t *testing.T) { + // Same test but with paths defined in opposite order + // Result should be the same - literal always wins + spec := `openapi: 3.1.0 +info: + title: Path Precedence Test + version: 1.0.0 +paths: + /Messages/Operations: + get: + operationId: getOperations + responses: + '200': + description: OK + /Messages/{message_id}: + get: + operationId: getMessage + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + request, _ := http.NewRequest(http.MethodGet, "https://api.com/Messages/Operations", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getOperations", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/Operations", foundPath) +} + +func TestFindPath_ParameterStillMatchesNonLiteral(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Path Precedence Test + version: 1.0.0 +paths: + /Messages/{message_id}: + get: + operationId: getMessage + responses: + '200': + description: OK + /Messages/Operations: + get: + operationId: getOperations + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request to a non-literal value should match parameter path + request, _ := http.NewRequest(http.MethodGet, "https://api.com/Messages/12345", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getMessage", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/{message_id}", foundPath) +} + +func TestFindPath_MultipleParameterLevels(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Path Precedence Test + version: 1.0.0 +paths: + /api/{version}/users/{id}: + get: + operationId: getUserVersioned + responses: + '200': + description: OK + /api/v1/users/{id}: + get: + operationId: getUserV1 + responses: + '200': + description: OK + /api/v1/users/me: + get: + operationId: getCurrentUser + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + tests := []struct { + url string + expectedOp string + expectedPath string + }{ + // Most specific: all literals + {"https://api.com/api/v1/users/me", "getCurrentUser", "/api/v1/users/me"}, + // More specific: 3 literals + 1 param + {"https://api.com/api/v1/users/123", "getUserV1", "/api/v1/users/{id}"}, + // Least specific: 2 literals + 2 params + {"https://api.com/api/v2/users/123", "getUserVersioned", "/api/{version}/users/{id}"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + request, _ := http.NewRequest(http.MethodGet, tt.url, nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, tt.expectedOp, pathItem.Get.OperationId) + assert.Equal(t, tt.expectedPath, foundPath) + }) + } +} + +func TestFindPath_TieBreaker_DefinitionOrder(t *testing.T) { + // When two paths have equal specificity (same number of literals/params), + // the first defined path should win + spec := `openapi: 3.1.0 +info: + title: Path Precedence Test + version: 1.0.0 +paths: + /pets/{petId}: + get: + operationId: getPetById + responses: + '200': + description: OK + /pets/{petName}: + get: + operationId: getPetByName + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + request, _ := http.NewRequest(http.MethodGet, "https://api.com/pets/fluffy", nil) + pathItem, _, foundPath := FindPath(request, &m.Model, nil) + + // First defined path wins when scores are equal + assert.Equal(t, "getPetById", pathItem.Get.OperationId) + assert.Equal(t, "/pets/{petId}", foundPath) +} + +func TestFindPath_PetsMinePrecedence(t *testing.T) { + // Classic example from OpenAPI spec: /pets/mine vs /pets/{petId} + spec := `openapi: 3.1.0 +info: + title: Petstore + version: 1.0.0 +paths: + /pets/{petId}: + get: + operationId: getPet + responses: + '200': + description: OK + /pets/mine: + get: + operationId: getMyPets + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // /pets/mine should match literal path + request, _ := http.NewRequest(http.MethodGet, "https://api.com/pets/mine", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.Equal(t, "getMyPets", pathItem.Get.OperationId) + assert.Equal(t, "/pets/mine", foundPath) + + // /pets/123 should match parameter path + request, _ = http.NewRequest(http.MethodGet, "https://api.com/pets/123", nil) + pathItem, errs, foundPath = FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.Equal(t, "getPet", pathItem.Get.OperationId) + assert.Equal(t, "/pets/{petId}", foundPath) +} + +func TestFindPath_DeepNestedPrecedence(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: Nested Paths + version: 1.0.0 +paths: + /api/{version}/resources/{id}/actions/{action}: + get: + operationId: genericAction + responses: + '200': + description: OK + /api/v1/resources/{id}/actions/delete: + get: + operationId: deleteResource + responses: + '200': + description: OK + /api/v1/resources/special/actions/delete: + get: + operationId: deleteSpecial + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + tests := []struct { + url string + expectedOp string + expectedPath string + }{ + // All literals - most specific + {"https://api.com/api/v1/resources/special/actions/delete", "deleteSpecial", "/api/v1/resources/special/actions/delete"}, + // 5 literals + 1 param + {"https://api.com/api/v1/resources/123/actions/delete", "deleteResource", "/api/v1/resources/{id}/actions/delete"}, + // 3 literals + 3 params - least specific + {"https://api.com/api/v2/resources/123/actions/update", "genericAction", "/api/{version}/resources/{id}/actions/{action}"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + request, _ := http.NewRequest(http.MethodGet, tt.url, nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, tt.expectedOp, pathItem.Get.OperationId) + assert.Equal(t, tt.expectedPath, foundPath) + }) + } +} + +func TestFindPath_MethodMismatchUsesHighestScore(t *testing.T) { + // When path matches but method doesn't exist, error should reference + // the most specific matching path + spec := `openapi: 3.1.0 +info: + title: Method Mismatch Test + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUser + responses: + '200': + description: OK + /users/admin: + get: + operationId: getAdmin + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // POST to /users/admin - literal path should be chosen for error + request, _ := http.NewRequest(http.MethodPost, "https://api.com/users/admin", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.NotNil(t, errs) + assert.Len(t, errs, 1) + assert.Equal(t, "/users/admin", foundPath) + assert.NotNil(t, pathItem) + assert.True(t, errs[0].IsOperationMissingError()) +} + +func TestFindPath_WithQueryParams(t *testing.T) { + // Ensure query params don't affect path matching precedence + spec := `openapi: 3.1.0 +info: + title: Query Params Test + version: 1.0.0 +paths: + /Messages/{message_id}: + get: + operationId: getMessage + responses: + '200': + description: OK + /Messages/Operations: + get: + operationId: getOperations + parameters: + - name: start_date + in: query + schema: + type: string + - name: end_date + in: query + schema: + type: string + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // This is the exact request from issue #181 + request, _ := http.NewRequest(http.MethodGet, + "https://api.com/Messages/Operations?start_date=2020-01-01T00:00:00Z&end_date=2025-12-31T23:59:59Z&page_size=10", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getOperations", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/Operations", foundPath) +} + +func TestFindPath_WithRegexCache(t *testing.T) { + // Ensure precedence works correctly with regex cache + spec := `openapi: 3.1.0 +info: + title: Cache Test + version: 1.0.0 +paths: + /Messages/{message_id}: + get: + operationId: getMessage + responses: + '200': + description: OK + /Messages/Operations: + get: + operationId: getOperations + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + regexCache := &sync.Map{} + + // First request - populates cache + request, _ := http.NewRequest(http.MethodGet, "https://api.com/Messages/Operations", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, regexCache) + + assert.Nil(t, errs) + assert.Equal(t, "getOperations", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/Operations", foundPath) + + // Second request - uses cache + request, _ = http.NewRequest(http.MethodGet, "https://api.com/Messages/12345", nil) + pathItem, errs, foundPath = FindPath(request, &m.Model, regexCache) + + assert.Nil(t, errs) + assert.Equal(t, "getMessage", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/{message_id}", foundPath) + + // Third request - still works correctly + request, _ = http.NewRequest(http.MethodGet, "https://api.com/Messages/Operations", nil) + pathItem, errs, foundPath = FindPath(request, &m.Model, regexCache) + + assert.Nil(t, errs) + assert.Equal(t, "getOperations", pathItem.Get.OperationId) + assert.Equal(t, "/Messages/Operations", foundPath) +} + +func TestFindPath_WithFragment(t *testing.T) { + // Test that request paths with fragments are handled correctly + spec := `openapi: 3.1.0 +info: + title: Fragment Test + version: 1.0.0 +paths: + /users/{id}: + get: + operationId: getUser + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request with fragment in URL + request, _ := http.NewRequest(http.MethodGet, "https://api.com/users/123#section", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getUser", pathItem.Get.OperationId) + assert.Equal(t, "/users/{id}", foundPath) +} + +func TestFindPath_WithTrailingSlashBasePath(t *testing.T) { + // Test that base paths with trailing slash work correctly + spec := `openapi: 3.1.0 +info: + title: Trailing Slash Test + version: 1.0.0 +servers: + - url: https://api.com/v1/ +paths: + /users: + get: + operationId: getUsers + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request to path that includes base with trailing slash + request, _ := http.NewRequest(http.MethodGet, "https://api.com/v1/users", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "getUsers", pathItem.Get.OperationId) + assert.Equal(t, "/users", foundPath) +} + +func TestFindPath_PathTemplateWithFragment_RequestWithoutFragment(t *testing.T) { + // Test that path templates with fragments are normalized when request has no fragment + // This covers normalizePathForMatching stripping fragment from template (line 115-117) + spec := `openapi: 3.1.0 +info: + title: Fragment Normalization Test + version: 1.0.0 +paths: + /hashy#section: + post: + operationId: postHashy + responses: + '200': + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // Request WITHOUT fragment should still match path template WITH fragment + // because normalizePathForMatching strips the fragment from template + request, _ := http.NewRequest(http.MethodPost, "https://api.com/hashy", nil) + pathItem, errs, foundPath := FindPath(request, &m.Model, nil) + + assert.Nil(t, errs) + assert.NotNil(t, pathItem) + assert.Equal(t, "postHashy", pathItem.Post.OperationId) + assert.Equal(t, "/hashy#section", foundPath) +} diff --git a/paths/specificity.go b/paths/specificity.go new file mode 100644 index 0000000..ddf8388 --- /dev/null +++ b/paths/specificity.go @@ -0,0 +1,93 @@ +// Copyright 2023-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package paths + +import ( + "net/http" + "strings" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +// pathCandidate represents a potential path match with metadata for selection. +type pathCandidate struct { + pathItem *v3.PathItem + path string + score int + hasMethod bool +} + +// computeSpecificityScore calculates how specific a path template is. +// literal segments score higher than parameterized segments, ensuring +// "/pets/mine" is preferred over "/pets/{id}" per OpenAPI spec. +// +// scoring: +// - literal segment: 1000 points +// - parameter segment: 1 point +// +// this weighting ensures any path with more literal segments always wins, +// regardless of parameter positions. +func computeSpecificityScore(pathTemplate string) int { + segments := strings.Split(pathTemplate, "/") + score := 0 + + for _, seg := range segments { + if seg == "" { + continue + } + if isParameterSegment(seg) { + score += 1 + } else { + score += 1000 + } + } + return score +} + +// isParameterSegment returns true if the segment contains a path parameter. +// handles standard {param}, label {.param}, and exploded {param*} formats. +func isParameterSegment(seg string) bool { + return strings.Contains(seg, "{") && strings.Contains(seg, "}") +} + +// pathHasMethod checks if the PathItem has an operation for the given HTTP method. +func pathHasMethod(pathItem *v3.PathItem, method string) bool { + switch method { + case http.MethodGet: + return pathItem.Get != nil + case http.MethodPost: + return pathItem.Post != nil + case http.MethodPut: + return pathItem.Put != nil + case http.MethodDelete: + return pathItem.Delete != nil + case http.MethodOptions: + return pathItem.Options != nil + case http.MethodHead: + return pathItem.Head != nil + case http.MethodPatch: + return pathItem.Patch != nil + case http.MethodTrace: + return pathItem.Trace != nil + } + return false +} + +// selectMatches finds the best matching candidates in a single pass. +// returns the highest-scoring candidate with the method (or nil), and +// the highest-scoring candidate overall (for error reporting). +func selectMatches(candidates []pathCandidate) (withMethod, highest *pathCandidate) { + for i := range candidates { + c := &candidates[i] + + if c.hasMethod && (withMethod == nil || c.score > withMethod.score) { + withMethod = c + } + + if highest == nil || c.score > highest.score { + highest = c + } + } + return withMethod, highest +} diff --git a/paths/specificity_test.go b/paths/specificity_test.go new file mode 100644 index 0000000..76b88e4 --- /dev/null +++ b/paths/specificity_test.go @@ -0,0 +1,314 @@ +// Copyright 2023-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package paths + +import ( + "testing" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/stretchr/testify/assert" +) + +func TestComputeSpecificityScore(t *testing.T) { + tests := []struct { + name string + path string + expected int + }{ + { + name: "single literal segment", + path: "/pets", + expected: 1000, + }, + { + name: "single parameter segment", + path: "/{id}", + expected: 1, + }, + { + name: "literal then parameter", + path: "/pets/{id}", + expected: 1001, + }, + { + name: "two literal segments", + path: "/pets/mine", + expected: 2000, + }, + { + name: "two parameter segments", + path: "/{tenant}/{id}", + expected: 2, + }, + { + name: "mixed - param literal param", + path: "/{tenant}/users/{id}", + expected: 1002, + }, + { + name: "three literal segments", + path: "/api/v1/users", + expected: 3000, + }, + { + name: "two literals one param", + path: "/api/v1/{resource}", + expected: 2001, + }, + { + name: "four literals", + path: "/api/v1/users/profile", + expected: 4000, + }, + { + name: "label parameter format", + path: "/burgers/{.burgerId}/locate", + expected: 2001, + }, + { + name: "exploded parameter format", + path: "/burgers/{burgerId*}/locate", + expected: 2001, + }, + { + name: "empty path", + path: "/", + expected: 0, + }, + { + name: "OData style path", + path: "/entities('{Entity}')", + expected: 1, + }, + { + name: "complex OData path", + path: "/orders(RelationshipNumber='{RelationshipNumber}',ValidityEndDate=datetime'{ValidityEndDate}')", + expected: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := computeSpecificityScore(tt.path) + assert.Equal(t, tt.expected, score, "path: %s", tt.path) + }) + } +} + +func TestIsParameterSegment(t *testing.T) { + tests := []struct { + segment string + expected bool + }{ + {"users", false}, + {"{id}", true}, + {"{.id}", true}, + {"{id*}", true}, + {"mine", false}, + {"", false}, + {"v1", false}, + {"{petId}", true}, + {"{message_id}", true}, + {"Operations", false}, + {"entities('{Entity}')", true}, + {"literal", false}, + } + + for _, tt := range tests { + t.Run(tt.segment, func(t *testing.T) { + result := isParameterSegment(tt.segment) + assert.Equal(t, tt.expected, result, "segment: %s", tt.segment) + }) + } +} + +func TestPathHasMethod(t *testing.T) { + tests := []struct { + name string + pathItem *v3.PathItem + method string + expected bool + }{ + { + name: "GET exists", + pathItem: &v3.PathItem{Get: &v3.Operation{}}, + method: "GET", + expected: true, + }, + { + name: "GET missing", + pathItem: &v3.PathItem{Post: &v3.Operation{}}, + method: "GET", + expected: false, + }, + { + name: "POST exists", + pathItem: &v3.PathItem{Post: &v3.Operation{}}, + method: "POST", + expected: true, + }, + { + name: "PUT exists", + pathItem: &v3.PathItem{Put: &v3.Operation{}}, + method: "PUT", + expected: true, + }, + { + name: "DELETE exists", + pathItem: &v3.PathItem{Delete: &v3.Operation{}}, + method: "DELETE", + expected: true, + }, + { + name: "OPTIONS exists", + pathItem: &v3.PathItem{Options: &v3.Operation{}}, + method: "OPTIONS", + expected: true, + }, + { + name: "HEAD exists", + pathItem: &v3.PathItem{Head: &v3.Operation{}}, + method: "HEAD", + expected: true, + }, + { + name: "PATCH exists", + pathItem: &v3.PathItem{Patch: &v3.Operation{}}, + method: "PATCH", + expected: true, + }, + { + name: "TRACE exists", + pathItem: &v3.PathItem{Trace: &v3.Operation{}}, + method: "TRACE", + expected: true, + }, + { + name: "unknown method", + pathItem: &v3.PathItem{Get: &v3.Operation{}}, + method: "UNKNOWN", + expected: false, + }, + { + name: "empty pathItem", + pathItem: &v3.PathItem{}, + method: "GET", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pathHasMethod(tt.pathItem, tt.method) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSelectMatches(t *testing.T) { + tests := []struct { + name string + candidates []pathCandidate + expectedWithMethod string // expected path for withMethod, or empty if nil + expectedHighest string // expected path for highest, or empty if nil + }{ + { + name: "single candidate with method", + candidates: []pathCandidate{ + {path: "/pets/{id}", score: 1001, hasMethod: true}, + }, + expectedWithMethod: "/pets/{id}", + expectedHighest: "/pets/{id}", + }, + { + name: "single candidate without method", + candidates: []pathCandidate{ + {path: "/pets/{id}", score: 1001, hasMethod: false}, + }, + expectedWithMethod: "", + expectedHighest: "/pets/{id}", + }, + { + name: "higher score wins", + candidates: []pathCandidate{ + {path: "/pets/{id}", score: 1001, hasMethod: true}, + {path: "/pets/mine", score: 2000, hasMethod: true}, + }, + expectedWithMethod: "/pets/mine", + expectedHighest: "/pets/mine", + }, + { + name: "higher score wins - reverse order", + candidates: []pathCandidate{ + {path: "/pets/mine", score: 2000, hasMethod: true}, + {path: "/pets/{id}", score: 1001, hasMethod: true}, + }, + expectedWithMethod: "/pets/mine", + expectedHighest: "/pets/mine", + }, + { + name: "higher score without method is skipped for withMethod", + candidates: []pathCandidate{ + {path: "/pets/{id}", score: 1001, hasMethod: true}, + {path: "/pets/mine", score: 2000, hasMethod: false}, + }, + expectedWithMethod: "/pets/{id}", + expectedHighest: "/pets/mine", + }, + { + name: "equal scores - first wins", + candidates: []pathCandidate{ + {path: "/pets/{petId}", score: 1001, hasMethod: true}, + {path: "/pets/{petName}", score: 1001, hasMethod: true}, + }, + expectedWithMethod: "/pets/{petId}", + expectedHighest: "/pets/{petId}", + }, + { + name: "empty candidates", + candidates: []pathCandidate{}, + expectedWithMethod: "", + expectedHighest: "", + }, + { + name: "all candidates without method", + candidates: []pathCandidate{ + {path: "/pets/{id}", score: 1001, hasMethod: false}, + {path: "/pets/mine", score: 2000, hasMethod: false}, + }, + expectedWithMethod: "", + expectedHighest: "/pets/mine", + }, + { + name: "three candidates mixed", + candidates: []pathCandidate{ + {path: "/{tenant}/users/{id}", score: 1002, hasMethod: true}, + {path: "/api/users/{id}", score: 2001, hasMethod: true}, + {path: "/api/users/me", score: 3000, hasMethod: true}, + }, + expectedWithMethod: "/api/users/me", + expectedHighest: "/api/users/me", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withMethod, highest := selectMatches(tt.candidates) + + if tt.expectedWithMethod == "" { + assert.Nil(t, withMethod) + } else { + assert.NotNil(t, withMethod) + assert.Equal(t, tt.expectedWithMethod, withMethod.path) + } + + if tt.expectedHighest == "" { + assert.Nil(t, highest) + } else { + assert.NotNil(t, highest) + assert.Equal(t, tt.expectedHighest, highest.path) + } + }) + } +} diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go index a74b6ae..bc96085 100644 --- a/requests/validate_body_test.go +++ b/requests/validate_body_test.go @@ -1495,3 +1495,84 @@ components: assert.Len(t, errors[0].SchemaValidationErrors, 1) assert.Equal(t, "'test' is not valid email: missing @", errors[0].SchemaValidationErrors[0].Reason) } + +func TestValidateBody_StrictMode_UndeclaredProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + patties: + type: integer` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewRequestBodyValidator(&m.Model, config.WithStrictMode()) + + // Include an undeclared property 'extra' + body := map[string]interface{}{ + "name": "Big Mac", + "patties": 2, + "extra": "undeclared property", + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateRequestBody(request) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "extra") + assert.Contains(t, errors[0].Message, "not declared") +} + +func TestValidateBody_StrictMode_ValidRequest(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + patties: + type: integer` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewRequestBodyValidator(&m.Model, config.WithStrictMode()) + + // Only declared properties + body := map[string]interface{}{ + "name": "Big Mac", + "patties": 2, + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateRequestBody(request) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} diff --git a/requests/validate_request.go b/requests/validate_request.go index 2d5aeca..0f5b9c4 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -25,6 +25,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/schema_validation" + "github.com/pb33f/libopenapi-validator/strict" ) var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`) @@ -314,6 +315,38 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V Context: referenceSchema, // attach the rendered schema to the error }) } + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared properties in request body + if validationOptions.StrictMode && decodedObj != nil { + strictValidator := strict.NewValidator(validationOptions, input.Version) + strictResult := strictValidator.Validate(strict.Input{ + Schema: schema, + Data: decodedObj, + Direction: strict.DirectionRequest, + Options: validationOptions, + BasePath: "$.body", + Version: input.Version, + }) + + if !strictResult.Valid { + for _, undeclared := range strictResult.UndeclaredValues { + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + )) + } + } + } + if len(validationErrors) > 0 { return false, validationErrors } diff --git a/responses/validate_body.go b/responses/validate_body.go index fc760db..4d532d8 100644 --- a/responses/validate_body.go +++ b/responses/validate_body.go @@ -109,8 +109,8 @@ func (v *responseBodyValidator) ValidateResponseBodyWithPathItem(request *http.R if foundResponse != nil { // check for headers in the response if foundResponse.Headers != nil { - if ok, herrs := ValidateResponseHeaders(request, response, foundResponse.Headers); !ok { - validationErrors = append(validationErrors, herrs...) + if ok, hErrs := ValidateResponseHeaders(request, response, foundResponse.Headers, config.WithExistingOpts(v.options)); !ok { + validationErrors = append(validationErrors, hErrs...) } } } diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index 5096225..a40bdd1 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -18,6 +19,7 @@ import ( "github.com/pb33f/libopenapi" "github.com/stretchr/testify/assert" + "github.com/pb33f/libopenapi-validator/config" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/paths" ) @@ -1511,6 +1513,86 @@ paths: } } +func TestValidateBody_StrictMode_UndeclaredProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/getBurger: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + name: + type: string + patties: + type: integer` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewResponseBodyValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/getBurger", nil) + + // Response with undeclared property 'extra' + responseBody := `{"name": "Big Mac", "patties": 2, "extra": "undeclared"}` + response := &http.Response{ + Header: http.Header{}, + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + response.Header.Set("Content-Type", "application/json") + + valid, errs := v.ValidateResponseBody(request, response) + + assert.False(t, valid) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Message, "extra") + assert.Contains(t, errs[0].Message, "not declared") +} + +func TestValidateBody_StrictMode_ValidResponse(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/getBurger: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + name: + type: string + patties: + type: integer` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewResponseBodyValidator(&m.Model, config.WithStrictMode()) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/burgers/getBurger", nil) + + // Response with only declared properties + responseBody := `{"name": "Big Mac", "patties": 2}` + response := &http.Response{ + Header: http.Header{}, + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + response.Header.Set("Content-Type", "application/json") + + valid, errs := v.ValidateResponseBody(request, response) + + assert.True(t, valid) + assert.Len(t, errs, 0) +} + type errorReader struct{} func (er *errorReader) Read(p []byte) (n int, err error) { diff --git a/responses/validate_headers.go b/responses/validate_headers.go index 5eb22df..0381b6d 100644 --- a/responses/validate_headers.go +++ b/responses/validate_headers.go @@ -17,6 +17,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/parameters" + "github.com/pb33f/libopenapi-validator/strict" ) // ValidateResponseHeaders validates the response headers against the OpenAPI spec. @@ -82,8 +83,35 @@ func ValidateResponseHeaders( } } } - if len(validationErrors) == 0 { - return true, nil + + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared response headers + if options.StrictMode { + // convert orderedmap to regular map for strict validation + declaredMap := make(map[string]*v3.Header) + for name, header := range headers.FromOldest() { + declaredMap[name] = header + } + + undeclaredHeaders := strict.ValidateResponseHeaders(response.Header, &declaredMap, options) + for _, undeclared := range undeclaredHeaders { + validationErrors = append(validationErrors, + errors.UndeclaredHeaderError( + undeclared.Name, + undeclared.Value.(string), + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + )) + } + } + + if len(validationErrors) > 0 { + return false, validationErrors } - return false, validationErrors + return true, nil } diff --git a/responses/validate_headers_test.go b/responses/validate_headers_test.go index feb5600..ba651ef 100644 --- a/responses/validate_headers_test.go +++ b/responses/validate_headers_test.go @@ -10,6 +10,8 @@ import ( "github.com/pb33f/libopenapi" "github.com/stretchr/testify/assert" + + "github.com/pb33f/libopenapi-validator/config" ) func TestValidateResponseHeaders(t *testing.T) { @@ -130,3 +132,91 @@ paths: assert.True(t, valid) assert.Len(t, errors, 0) } + +func TestValidateResponseHeaders_StrictMode(t *testing.T) { + spec := `openapi: "3.0.0" +info: + title: Healthcheck + version: '0.1.0' +paths: + /health: + get: + responses: + '200': + headers: + x-request-id: + description: request ID + required: false + schema: + type: string + description: healthy response` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + // build a request + request, _ := http.NewRequest(http.MethodGet, "https://things.com/health", nil) + + // simulate a response with an undeclared header + res := httptest.NewRecorder() + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Request-Id", "abc-123") + w.Header().Set("X-Undeclared-Header", "should fail in strict mode") + w.WriteHeader(http.StatusOK) + } + + handler(res, request) + response := res.Result() + + headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers + + // validate with strict mode - should find undeclared header + valid, errors := ValidateResponseHeaders(request, response, headers, config.WithStrictMode()) + + assert.False(t, valid) + assert.Len(t, errors, 1) + assert.Contains(t, errors[0].Message, "X-Undeclared-Header") + assert.Contains(t, errors[0].Message, "not declared") +} + +func TestValidateResponseHeaders_StrictMode_NoUndeclared(t *testing.T) { + spec := `openapi: "3.0.0" +info: + title: Healthcheck + version: '0.1.0' +paths: + /health: + get: + responses: + '200': + headers: + x-request-id: + description: request ID + required: false + schema: + type: string + description: healthy response` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + m, _ := doc.BuildV3Model() + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/health", nil) + + // response with only declared headers (x-request-id is declared, Content-Type is default-ignored) + res := httptest.NewRecorder() + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Request-Id", "abc-123") + w.WriteHeader(http.StatusOK) + } + + handler(res, request) + response := res.Result() + + headers := m.Model.Paths.PathItems.GetOrZero("/health").Get.Responses.Codes.GetOrZero("200").Headers + + // validate with strict mode - should pass (no undeclared headers) + valid, errors := ValidateResponseHeaders(request, response, headers, config.WithStrictMode()) + + assert.True(t, valid) + assert.Len(t, errors, 0) +} diff --git a/responses/validate_response.go b/responses/validate_response.go index edeaaee..5ebd255 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -25,6 +25,7 @@ import ( "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" "github.com/pb33f/libopenapi-validator/schema_validation" + "github.com/pb33f/libopenapi-validator/strict" ) var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`) @@ -329,6 +330,38 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors Context: referenceSchema, // attach the rendered schema to the error }) } + if len(validationErrors) > 0 { + return false, validationErrors + } + + // strict mode: check for undeclared properties in response body + if validationOptions.StrictMode && decodedObj != nil { + strictValidator := strict.NewValidator(validationOptions, input.Version) + strictResult := strictValidator.Validate(strict.Input{ + Schema: schema, + Data: decodedObj, + Direction: strict.DirectionResponse, + Options: validationOptions, + BasePath: "$.body", + Version: input.Version, + }) + + if !strictResult.Valid { + for _, undeclared := range strictResult.UndeclaredValues { + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + )) + } + } + } + if len(validationErrors) > 0 { return false, validationErrors } diff --git a/strict/array_validator.go b/strict/array_validator.go new file mode 100644 index 0000000..0660530 --- /dev/null +++ b/strict/array_validator.go @@ -0,0 +1,107 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "strconv" + + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// validateArray checks an array value against a schema for undeclared properties +// within array items. It handles: +// - items (schema for all items or boolean) +// - prefixItems (tuple validation with positional schemas) +// - unevaluatedItems (items not covered by items/prefixItems) +func (v *Validator) validateArray(ctx *traversalContext, schema *base.Schema, data []any) []UndeclaredValue { + if len(data) == 0 { + return nil + } + + var undeclared []UndeclaredValue + + // Check for items: false + // When items: false, no items are allowed. If base validation passed, the + // array should be empty. But we explicitly check in case it wasn't caught. + if schema.Items != nil && schema.Items.IsB() && !schema.Items.B { + for i := range data { + itemPath := buildArrayPath(ctx.path, i) + undeclared = append(undeclared, + newUndeclaredItem(itemPath, strconv.Itoa(i), data[i], ctx.direction)) + } + return undeclared + } + + prefixLen := 0 + + // handle prefixItems first (tuple validation) + if len(schema.PrefixItems) > 0 { + for i, itemProxy := range schema.PrefixItems { + if i >= len(data) { + break + } + + itemPath := buildArrayPath(ctx.path, i) + itemCtx := ctx.withPath(itemPath) + + if itemCtx.shouldIgnore() { + prefixLen++ + continue + } + + itemSchema := itemProxy.Schema() + if itemSchema != nil { + undeclared = append(undeclared, v.validateValue(itemCtx, itemSchema, data[i])...) + } + prefixLen++ + } + } + + // handle items for remaining elements (after prefixItems) + if schema.Items != nil && schema.Items.A != nil { + itemProxy := schema.Items.A + itemSchema := itemProxy.Schema() + + if itemSchema != nil { + for i := prefixLen; i < len(data); i++ { + itemPath := buildArrayPath(ctx.path, i) + itemCtx := ctx.withPath(itemPath) + + if itemCtx.shouldIgnore() { + continue + } + + undeclared = append(undeclared, v.validateValue(itemCtx, itemSchema, data[i])...) + } + } + } + + // handle unevaluatedItems with schema. + // unevaluatedItems: false is handled by base validation. + // unevaluatedItems: {schema} means items matching the schema are valid. + // note: this doesn't account for items evaluated by `contains`. for strict + // validation this is acceptable as we check conservatively. + if schema.UnevaluatedItems != nil && schema.UnevaluatedItems.Schema() != nil { + // this applies to items not covered by items or prefixItems. + // if there's no items schema, unevaluatedItems applies to: + // - items after prefixItems (if prefixItems exists) + // - all items (if neither items nor prefixItems exists) + if schema.Items == nil { + unevalSchema := schema.UnevaluatedItems.Schema() + startIndex := len(schema.PrefixItems) // 0 if no prefixItems + for i := startIndex; i < len(data); i++ { + itemPath := buildArrayPath(ctx.path, i) + itemCtx := ctx.withPath(itemPath) + + if itemCtx.shouldIgnore() { + continue + } + + undeclared = append(undeclared, v.validateValue(itemCtx, unevalSchema, data[i])...) + } + } + } + + return undeclared +} diff --git a/strict/headers.go b/strict/headers.go new file mode 100644 index 0000000..823848c --- /dev/null +++ b/strict/headers.go @@ -0,0 +1,35 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import "strings" + +// isHeaderIgnored checks if a header name should be ignored in strict validation. +// Uses the effective ignored headers list from options (defaults, replaced, or merged). +// Set-Cookie is direction-aware: ignored in responses but reported in requests. +func (v *Validator) isHeaderIgnored(name string, direction Direction) bool { + lower := strings.ToLower(name) + + // Set-Cookie is expected in responses but unexpected in requests + if lower == "set-cookie" { + return direction == DirectionResponse + } + + // Check effective ignored list + for _, h := range v.getEffectiveIgnoredHeaders() { + if strings.ToLower(h) == lower { + return true + } + } + return false +} + +// getEffectiveIgnoredHeaders returns the list of headers to ignore based on +// configuration. Uses the ValidationOptions method for consistency. +func (v *Validator) getEffectiveIgnoredHeaders() []string { + if v.options == nil { + return nil + } + return v.options.GetEffectiveStrictIgnoredHeaders() +} diff --git a/strict/matcher.go b/strict/matcher.go new file mode 100644 index 0000000..89bb2c3 --- /dev/null +++ b/strict/matcher.go @@ -0,0 +1,120 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "errors" + "fmt" + + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/pb33f/libopenapi/utils" + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/pb33f/libopenapi-validator/helpers" +) + +// dataMatchesSchema checks if the given data matches the schema using +// JSON Schema validation. This is used for: +// - oneOf/anyOf variant selection (finding which variant the data matches) +// - if/then/else condition evaluation +// - additionalProperties schema matching +// +// The method uses version-aware schema compilation to handle OpenAPI 3.0 vs 3.1 +// differences (especially nullable handling). +// +// Returns (true, nil) if data matches the schema. +// Returns (false, nil) if data does not match the schema. +// Returns (false, error) if schema compilation failed. +func (v *Validator) dataMatchesSchema(schema *base.Schema, data any) (bool, error) { + if schema == nil { + return true, nil // No schema means anything matches + } + + compiled, err := v.getCompiledSchema(schema) + if err != nil { + return false, err + } + return compiled.Validate(data) == nil, nil +} + +// getCompiledSchema returns a compiled JSON Schema for the given high-level schema. +// It checks multiple cache levels: +// 1. Global SchemaCache (if configured in options) +// 2. Local instance cache (for reuse within this validation call) +// 3. Compiles on-the-fly if not cached +// +// Returns the compiled schema and nil error on success. +// Returns nil schema and nil error if the input schema is nil. +// Returns nil schema and error if compilation failed. +func (v *Validator) getCompiledSchema(schema *base.Schema) (*jsonschema.Schema, error) { + if schema == nil || schema.GoLow() == nil { + return nil, nil + } + + hash := schema.GoLow().Hash() + hashKey := fmt.Sprintf("%x", hash) + + // try global cache first (if available) + if v.options != nil && v.options.SchemaCache != nil { + if cached, ok := v.options.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil { + return cached.CompiledSchema, nil + } + } + + // try local instance cache + if compiled, ok := v.localCache[hashKey]; ok { + return compiled, nil + } + + // cache miss - compile on-the-fly with context-aware rendering + compiled, err := v.compileSchema(schema) + if err != nil { + return nil, err + } + if compiled != nil { + v.localCache[hashKey] = compiled + } + + return compiled, nil +} + +// compileSchema renders and compiles a schema for validation. +// Uses RenderInlineWithContext for safe cycle handling. +// +// Returns the compiled schema and nil error on success. +// Returns nil schema and error if any step fails (render, conversion, compilation). +func (v *Validator) compileSchema(schema *base.Schema) (*jsonschema.Schema, error) { + if schema == nil { + return nil, nil + } + + schemaHash := fmt.Sprintf("%x", schema.GoLow().Hash()) + + // use RenderInlineWithContext for safe cycle handling + renderedSchema, err := schema.RenderInlineWithContext(v.renderCtx) + if err != nil { + return nil, fmt.Errorf("strict: schema render failed (hash=%s): %w", schemaHash, err) + } + + jsonSchema, convErr := utils.ConvertYAMLtoJSON(renderedSchema) + if convErr != nil { + return nil, fmt.Errorf("strict: YAML to JSON conversion failed: %w", convErr) + } + if len(jsonSchema) == 0 { + return nil, errors.New("strict: schema rendered to empty JSON") + } + + schemaName := fmt.Sprintf("strict-match-%s", schemaHash) + compiled, err := helpers.NewCompiledSchemaWithVersion( + schemaName, + jsonSchema, + v.options, + v.version, + ) + if err != nil { + return nil, fmt.Errorf("strict: schema compilation failed (name=%s): %w", schemaName, err) + } + + return compiled, nil +} diff --git a/strict/polymorphic.go b/strict/polymorphic.go new file mode 100644 index 0000000..e2617ba --- /dev/null +++ b/strict/polymorphic.go @@ -0,0 +1,469 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "regexp" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// validatePolymorphic handles allOf, oneOf, and anyOf schemas. +// For allOf: merge all schemas and validate against all. +// For oneOf/anyOf: find the matching variant and validate against it. +func (v *Validator) validatePolymorphic(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var undeclared []UndeclaredValue + + // Handle allOf first - data must match ALL schemas + if len(schema.AllOf) > 0 { + undeclared = append(undeclared, v.validateAllOf(ctx, schema, data)...) + } + + // Handle oneOf - data must match exactly ONE schema + if len(schema.OneOf) > 0 { + undeclared = append(undeclared, v.validateOneOf(ctx, schema, data)...) + } + + // Handle anyOf - data must match at least ONE schema + if len(schema.AnyOf) > 0 { + undeclared = append(undeclared, v.validateAnyOf(ctx, schema, data)...) + } + + // Also validate any direct properties on the parent schema + if schema.Properties != nil { + declared, patterns := v.collectDeclaredProperties(schema, data) + + // Check properties that aren't handled by allOf/oneOf/anyOf + for propName := range data { + // Skip if declared directly or via patterns + if isPropertyDeclared(propName, declared, patterns) { + continue + } + + // Check if it's declared in any of the allOf schemas + if v.isPropertyDeclaredInAllOf(schema.AllOf, propName) { + continue + } + + // For oneOf/anyOf, we've already validated against the matching variant + } + } + + return undeclared +} + +// validateAllOf validates data against all schemas in allOf. +// Collects properties from all schemas as declared. +func (v *Validator) validateAllOf(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var undeclared []UndeclaredValue + + // Collect declared properties from ALL schemas in allOf + allDeclared := make(map[string]*declaredProperty) + var allPatterns []*regexp.Regexp + + for _, schemaProxy := range schema.AllOf { + if schemaProxy == nil { + continue + } + + subSchema := schemaProxy.Schema() + if subSchema == nil { + continue + } + + declared, patterns := v.collectDeclaredProperties(subSchema, data) + for name, prop := range declared { + if _, exists := allDeclared[name]; !exists { + allDeclared[name] = prop + } + } + + allPatterns = append(allPatterns, patterns...) + } + + // collect from parent schema + declared, patterns := v.collectDeclaredProperties(schema, data) + for name, prop := range declared { + if _, exists := allDeclared[name]; !exists { + allDeclared[name] = prop + } + } + + allPatterns = append(allPatterns, patterns...) + + // check if strict mode should report for this combined schema + if !v.shouldReportUndeclaredForAllOf(schema) { + // Still recurse into declared properties + return v.recurseIntoAllOfDeclaredProperties(ctx, schema.AllOf, data, allDeclared) + } + + // Check each property in data + for propName, propValue := range data { + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + // Check if declared in merged schema + if isPropertyDeclared(propName, allDeclared, allPatterns) { + // Recurse into the property + propSchema := v.findPropertySchemaInAllOf(schema.AllOf, propName, allDeclared) + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + continue + } + + // Not declared - report as undeclared + undeclared = append(undeclared, + newUndeclaredProperty(propPath, propName, propValue, getDeclaredPropertyNames(allDeclared), ctx.direction)) + } + + return undeclared +} + +// validateOneOf finds the matching oneOf variant and validates against it. +// Parent schema properties are merged with the variant's properties. +func (v *Validator) validateOneOf(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var matchingVariant *base.Schema + + // discriminator is present, use it to select the variant + if schema.Discriminator != nil { + matchingVariant = v.selectByDiscriminator(schema, schema.OneOf, data) + } + + // no discriminator or no match: find matching variant by validation + if matchingVariant == nil { + matchingVariant = v.findMatchingVariant(schema.OneOf, data) + } + + if matchingVariant == nil { + // No match found - base validation would report this error + return nil + } + + // Validate against variant, but filter out properties declared in parent + return v.validateVariantWithParent(ctx, schema, matchingVariant, data) +} + +// validateAnyOf finds matching anyOf variants and validates against them. +// Parent schema properties are merged with the variant's properties. +func (v *Validator) validateAnyOf(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var matchingVariant *base.Schema + + // If discriminator is present, use it to select the variant + if schema.Discriminator != nil { + matchingVariant = v.selectByDiscriminator(schema, schema.AnyOf, data) + } + + // No discriminator or no match: find matching variant by validation + if matchingVariant == nil { + matchingVariant = v.findMatchingVariant(schema.AnyOf, data) + } + + if matchingVariant == nil { + // No match found - base validation would report this error + return nil + } + + // Validate against variant, but filter out properties declared in parent + return v.validateVariantWithParent(ctx, schema, matchingVariant, data) +} + +// validateVariantWithParent validates data against a variant schema while also +// considering properties declared in the parent schema. This ensures parent +// properties are not reported as undeclared when using oneOf/anyOf. +func (v *Validator) validateVariantWithParent(ctx *traversalContext, parent *base.Schema, variant *base.Schema, data map[string]any) []UndeclaredValue { + var undeclared []UndeclaredValue + + // Collect declared properties from parent schema + parentDeclared, parentPatterns := v.collectDeclaredProperties(parent, data) + + // Collect declared properties from variant schema + variantDeclared, variantPatterns := v.collectDeclaredProperties(variant, data) + + // Merge: parent + variant + allDeclared := make(map[string]*declaredProperty) + for name, prop := range parentDeclared { + allDeclared[name] = prop + } + for name, prop := range variantDeclared { + allDeclared[name] = prop + } + allPatterns := append(parentPatterns, variantPatterns...) + + // Check if we should report undeclared (skip if additionalProperties: false) + if !v.shouldReportUndeclared(variant) && !v.shouldReportUndeclared(parent) { + // Still recurse into declared properties + return v.recurseIntoDeclaredPropertiesWithMerged(ctx, variant, parent, data, allDeclared) + } + + // Check each property in data + for propName, propValue := range data { + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + // Check if declared in merged schema (parent + variant) + if isPropertyDeclared(propName, allDeclared, allPatterns) { + // Find the property schema (prefer variant, fallback to parent) + propSchema := v.findPropertySchemaInMerged(variant, parent, propName, allDeclared) + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + continue + } + + // Not declared - report as undeclared + undeclared = append(undeclared, + newUndeclaredProperty(propPath, propName, propValue, getDeclaredPropertyNames(allDeclared), ctx.direction)) + } + + return undeclared +} + +// findPropertySchemaInMerged finds the schema for a property, preferring variant over parent. +// Checks explicit properties first, then patternProperties. +func (v *Validator) findPropertySchemaInMerged(variant, parent *base.Schema, propName string, declared map[string]*declaredProperty) *base.Schema { + // Check explicit declared first + if prop, ok := declared[propName]; ok && prop.proxy != nil { + return prop.proxy.Schema() + } + + // Check variant schema explicit properties + if variant != nil && variant.Properties != nil { + if propProxy, exists := variant.Properties.Get(propName); exists && propProxy != nil { + return propProxy.Schema() + } + } + + // Check parent schema explicit properties + if parent != nil && parent.Properties != nil { + if propProxy, exists := parent.Properties.Get(propName); exists && propProxy != nil { + return propProxy.Schema() + } + } + + // Check variant patternProperties + if variant != nil { + if propProxy := v.getPatternPropertySchema(variant, propName); propProxy != nil { + return propProxy.Schema() + } + } + + // Check parent patternProperties + if parent != nil { + if propProxy := v.getPatternPropertySchema(parent, propName); propProxy != nil { + return propProxy.Schema() + } + } + + return nil +} + +// recurseIntoDeclaredPropertiesWithMerged recurses into properties from merged parent+variant. +func (v *Validator) recurseIntoDeclaredPropertiesWithMerged(ctx *traversalContext, variant, parent *base.Schema, data map[string]any, declared map[string]*declaredProperty) []UndeclaredValue { + var undeclared []UndeclaredValue + + for propName, propValue := range data { + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + propSchema := v.findPropertySchemaInMerged(variant, parent, propName, declared) + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + } + + return undeclared +} + +// selectByDiscriminator uses the discriminator to select the appropriate variant. +func (v *Validator) selectByDiscriminator(schema *base.Schema, variants []*base.SchemaProxy, data map[string]any) *base.Schema { + if schema.Discriminator == nil { + return nil + } + + propName := schema.Discriminator.PropertyName + if propName == "" { + return nil + } + + discriminatorValue, ok := data[propName] + if !ok { + return nil + } + + valueStr, ok := discriminatorValue.(string) + if !ok { + return nil + } + + // check mapping first + if schema.Discriminator.Mapping != nil { + for pair := schema.Discriminator.Mapping.First(); pair != nil; pair = pair.Next() { + if pair.Key() == valueStr { + // The mapping value is a reference like "#/components/schemas/Dog" + mappedRef := pair.Value() + for _, variantProxy := range variants { + if variantProxy.IsReference() && variantProxy.GetReference() == mappedRef { + return variantProxy.Schema() + } + } + } + } + } + + // no mapping match, try to match by schema name in reference + for _, variantProxy := range variants { + if variantProxy.IsReference() { + ref := variantProxy.GetReference() + // Extract schema name from reference like "#/components/schemas/Dog" + parts := strings.Split(ref, "/") + if len(parts) > 0 && parts[len(parts)-1] == valueStr { + return variantProxy.Schema() + } + } + } + + return nil +} + +// findMatchingVariant finds the first variant that the data validates against. +func (v *Validator) findMatchingVariant(variants []*base.SchemaProxy, data map[string]any) *base.Schema { + for _, variantProxy := range variants { + if variantProxy == nil { + continue + } + + variantSchema := variantProxy.Schema() + if variantSchema == nil { + continue + } + + matches, _ := v.dataMatchesSchema(variantSchema, data) + if matches { + return variantSchema + } + } + return nil +} + +// isPropertyDeclaredInAllOf checks if a property is declared in any allOf schema. +func (v *Validator) isPropertyDeclaredInAllOf(allOf []*base.SchemaProxy, propName string) bool { + for _, schemaProxy := range allOf { + if schemaProxy == nil { + continue + } + + subSchema := schemaProxy.Schema() + if subSchema == nil { + continue + } + + if subSchema.Properties != nil { + if _, exists := subSchema.Properties.Get(propName); exists { + return true + } + } + } + return false +} + +// shouldReportUndeclaredForAllOf checks if any schema in allOf disables additional properties. +func (v *Validator) shouldReportUndeclaredForAllOf(schema *base.Schema) bool { + // Check parent schema + if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsB() && !schema.AdditionalProperties.B { + return false + } + + // Check each allOf schema + for _, schemaProxy := range schema.AllOf { + if schemaProxy == nil { + continue + } + + subSchema := schemaProxy.Schema() + if subSchema == nil { + continue + } + + if subSchema.AdditionalProperties != nil && subSchema.AdditionalProperties.IsB() && !subSchema.AdditionalProperties.B { + return false + } + } + + return true +} + +// findPropertySchemaInAllOf finds the schema for a property in allOf schemas. +func (v *Validator) findPropertySchemaInAllOf(allOf []*base.SchemaProxy, propName string, declared map[string]*declaredProperty) *base.Schema { + // Check explicit declared first + if prop, ok := declared[propName]; ok && prop.proxy != nil { + return prop.proxy.Schema() + } + + // Search in allOf schemas + for _, schemaProxy := range allOf { + if schemaProxy == nil { + continue + } + + subSchema := schemaProxy.Schema() + if subSchema == nil { + continue + } + + if subSchema.Properties != nil { + if propProxy, exists := subSchema.Properties.Get(propName); exists && propProxy != nil { + return propProxy.Schema() + } + } + } + + return nil +} + +// recurseIntoAllOfDeclaredProperties recurses into properties without checking for undeclared. +func (v *Validator) recurseIntoAllOfDeclaredProperties(ctx *traversalContext, allOf []*base.SchemaProxy, data map[string]any, declared map[string]*declaredProperty) []UndeclaredValue { + var undeclared []UndeclaredValue + + for propName, propValue := range data { + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + propSchema := v.findPropertySchemaInAllOf(allOf, propName, declared) + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + } + + return undeclared +} diff --git a/strict/property_collector.go b/strict/property_collector.go new file mode 100644 index 0000000..24317b8 --- /dev/null +++ b/strict/property_collector.go @@ -0,0 +1,168 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "regexp" + + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// declaredProperty holds information about a declared property in a schema. +type declaredProperty struct { + // proxy is the SchemaProxy for the property. + proxy *base.SchemaProxy +} + +// collectDeclaredProperties gathers all property names that are declared in a schema. +// This includes explicit properties, patternProperties matches, and properties from +// dependentSchemas and if/then/else based on the actual data. +// +// Returns a map from property name to its declaration info, plus a slice of +// pattern regexes for patternProperties matching. +func (v *Validator) collectDeclaredProperties( + schema *base.Schema, + data map[string]any, +) (declared map[string]*declaredProperty, patterns []*regexp.Regexp) { + declared = make(map[string]*declaredProperty) + + if schema == nil { + return declared, nil + } + + // explicit properties + if schema.Properties != nil { + for pair := schema.Properties.First(); pair != nil; pair = pair.Next() { + declared[pair.Key()] = &declaredProperty{ + proxy: pair.Value(), + } + } + } + + // pattern properties - use cached compiled patterns + if schema.PatternProperties != nil { + for pair := schema.PatternProperties.First(); pair != nil; pair = pair.Next() { + pattern := v.getCompiledPattern(pair.Key()) + if pattern == nil { + continue + } + patterns = append(patterns, pattern) + } + } + + // dependent schemas - if trigger property exists in data + if schema.DependentSchemas != nil { + for pair := schema.DependentSchemas.First(); pair != nil; pair = pair.Next() { + triggerProp := pair.Key() + if _, exists := data[triggerProp]; !exists { + continue + } + // trigger property exists, include dependent schema's properties + mergePropertiesIntoDeclared(declared, pair.Value().Schema()) + } + } + + // if/then/else + if schema.If != nil { + ifProxy := schema.If + ifSchema := ifProxy.Schema() + if ifSchema != nil { + matches, err := v.dataMatchesSchema(ifSchema, data) + if err != nil { + // schema compilation failed - log and use else branch + v.logger.Debug("strict: if schema compilation failed, using else branch", "error", err) + matches = false + } + if matches { + if schema.Then != nil { + mergePropertiesIntoDeclared(declared, schema.Then.Schema()) + } + } else { + if schema.Else != nil { + mergePropertiesIntoDeclared(declared, schema.Else.Schema()) + } + } + } + } + + return declared, patterns +} + +// mergePropertiesIntoDeclared merges properties from a schema's Properties map into +// the declared map. Only adds properties that are not already declared. +// This eliminates code duplication when collecting properties from multiple sources. +func mergePropertiesIntoDeclared(declared map[string]*declaredProperty, schema *base.Schema) { + if schema == nil || schema.Properties == nil { + return + } + for p := schema.Properties.First(); p != nil; p = p.Next() { + if _, alreadyDeclared := declared[p.Key()]; !alreadyDeclared { + declared[p.Key()] = &declaredProperty{ + proxy: p.Value(), + } + } + } +} + +// getDeclaredPropertyNames returns just the property names from declared properties. +func getDeclaredPropertyNames(declared map[string]*declaredProperty) []string { + if len(declared) == 0 { + return nil + } + names := make([]string, 0, len(declared)) + for name := range declared { + names = append(names, name) + } + return names +} + +// isPropertyDeclared checks if a property name is declared in the schema. +// A property is declared if: +// - It's in the explicit properties map +// - It matches any patternProperties regex +func isPropertyDeclared(name string, declared map[string]*declaredProperty, patterns []*regexp.Regexp) bool { + // check explicit properties + if _, ok := declared[name]; ok { + return true + } + + // check pattern properties + for _, pattern := range patterns { + if pattern.MatchString(name) { + return true + } + } + + return false +} + +// getPropertySchema returns the SchemaProxy for a declared property. +// Returns nil if the property is not declared or is only matched by pattern. +func getPropertySchema(name string, declared map[string]*declaredProperty) *base.SchemaProxy { + // check explicit properties first + if dp, ok := declared[name]; ok && dp.proxy != nil { + return dp.proxy + } + return nil +} + +// shouldSkipProperty checks if a property should be skipped based on +// readOnly/writeOnly and the current validation direction. +func (v *Validator) shouldSkipProperty(schema *base.Schema, direction Direction) bool { + if schema == nil { + return false + } + + // readOnly: skip in requests (should not be sent by client) + if direction == DirectionRequest && schema.ReadOnly != nil && *schema.ReadOnly { + return true + } + + // writeOnly: skip in responses (should not be returned by server) + if direction == DirectionResponse && schema.WriteOnly != nil && *schema.WriteOnly { + return true + } + + return false +} diff --git a/strict/schema_walker.go b/strict/schema_walker.go new file mode 100644 index 0000000..0489795 --- /dev/null +++ b/strict/schema_walker.go @@ -0,0 +1,234 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "github.com/pb33f/libopenapi/datamodel/high/base" +) + +// validateValue is the main entry point for validating a value against a schema. +// It dispatches to the appropriate handler based on the value type. +func (v *Validator) validateValue(ctx *traversalContext, schema *base.Schema, data any) []UndeclaredValue { + if schema == nil || data == nil { + return nil + } + + if ctx.shouldIgnore() { + return nil + } + + if ctx.exceedsDepth() { + return nil + } + + // check for cycles using schema hash + schemaKey := v.getSchemaKey(schema) + if ctx.checkAndMarkVisited(schemaKey) { + return nil + } + + // switch on data type + switch val := data.(type) { + case map[string]any: + return v.validateObject(ctx, schema, val) + case []any: + return v.validateArray(ctx, schema, val) + default: + return nil + } +} + +// validateObject checks an object value against a schema for undeclared properties. +func (v *Validator) validateObject(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var undeclared []UndeclaredValue + + if len(schema.AllOf) > 0 || len(schema.OneOf) > 0 || len(schema.AnyOf) > 0 { + return v.validatePolymorphic(ctx, schema, data) + } + + if !v.shouldReportUndeclared(schema) { + // additionalProperties: false - base validation catches this, no strict check needed + // Still need to recurse into declared properties + return v.recurseIntoDeclaredProperties(ctx, schema, data) + } + + declared, patterns := v.collectDeclaredProperties(schema, data) + + // check each property in the data + for propName, propValue := range data { + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + if !isPropertyDeclared(propName, declared, patterns) { + undeclared = append(undeclared, + newUndeclaredProperty(propPath, propName, propValue, getDeclaredPropertyNames(declared), ctx.direction)) + + // even if undeclared, recurse into additionalProperties schema if present + if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsA() { + addlProxy := schema.AdditionalProperties.A + if addlProxy != nil { + addlSchema := addlProxy.Schema() + if addlSchema != nil { + undeclared = append(undeclared, v.validateValue(propCtx, addlSchema, propValue)...) + } + } + } + continue + } + + // property is declared, recurse into it + propProxy := getPropertySchema(propName, declared) + if propProxy == nil { + propProxy = v.getPatternPropertySchema(schema, propName) + } + + if propProxy != nil { + propSchema := propProxy.Schema() + if propSchema != nil { + // check readOnly/writeOnly + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + } + } + + return undeclared +} + +// shouldReportUndeclared determines if strict mode should report undeclared +// properties for this schema. +func (v *Validator) shouldReportUndeclared(schema *base.Schema) bool { + if schema == nil { + return false + } + + // SHORT-CIRCUIT: If additionalProperties: false, base validation already catches extras. + if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsB() && !schema.AdditionalProperties.B { + return false + } + + // STRICT OVERRIDE: Even if additionalProperties: true, report undeclared. + if schema.AdditionalProperties != nil { + if schema.AdditionalProperties.IsB() && schema.AdditionalProperties.B { + return true + } + if schema.AdditionalProperties.IsA() { + // additionalProperties with schema - properties matching schema are + // technically "declared" but we still want to flag them as not in + // the explicit schema. They will be recursed into. + return true + } + } + + // STRICT OVERRIDE: unevaluatedProperties: false with implicit additionalProperties: true + // Standard JSON Schema would catch via unevaluatedProperties, but strict reports + // even when additionalProperties: true would normally allow extras. + if schema.UnevaluatedProperties != nil && schema.UnevaluatedProperties.IsB() && !schema.UnevaluatedProperties.B { + // unevaluatedProperties: false means base validation catches extras + // BUT if there's no additionalProperties: false, strict should report + return true + } + + // default: no additionalProperties means implicit true in JSON Schema + // Strict reports undeclared in this case + return true +} + +// getPatternPropertySchema finds the schema for a property that matches +// a patternProperties regex. Uses cached compiled patterns. +func (v *Validator) getPatternPropertySchema(schema *base.Schema, propName string) *base.SchemaProxy { + if schema.PatternProperties == nil { + return nil + } + + for pair := schema.PatternProperties.First(); pair != nil; pair = pair.Next() { + pattern := v.getCompiledPattern(pair.Key()) + if pattern == nil { + continue + } + if pattern.MatchString(propName) { + return pair.Value() + } + } + + return nil +} + +// recurseIntoDeclaredProperties recurses into declared properties without +// checking for undeclared (used when additionalProperties: false). +// This includes both explicit properties and patternProperties matches. +func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema *base.Schema, data map[string]any) []UndeclaredValue { + var undeclared []UndeclaredValue + + processed := make(map[string]bool) + + // process explicit properties + if schema.Properties != nil { + for pair := schema.Properties.First(); pair != nil; pair = pair.Next() { + propName := pair.Key() + propProxy := pair.Value() + + propValue, exists := data[propName] + if !exists { + continue + } + + processed[propName] = true + + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + propSchema := propProxy.Schema() + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + } + } + + // process patternProperties - recurse into any data properties that match patterns + if schema.PatternProperties != nil { + for propName, propValue := range data { + if processed[propName] { + continue + } + + propProxy := v.getPatternPropertySchema(schema, propName) + if propProxy == nil { + continue + } + + processed[propName] = true + + propPath := buildPath(ctx.path, propName) + propCtx := ctx.withPath(propPath) + + if propCtx.shouldIgnore() { + continue + } + + propSchema := propProxy.Schema() + if propSchema != nil { + if v.shouldSkipProperty(propSchema, ctx.direction) { + continue + } + undeclared = append(undeclared, v.validateValue(propCtx, propSchema, propValue)...) + } + } + } + + return undeclared +} diff --git a/strict/types.go b/strict/types.go new file mode 100644 index 0000000..163cf80 --- /dev/null +++ b/strict/types.go @@ -0,0 +1,364 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package strict provides strict validation that detects undeclared +// properties in requests and responses, even when additionalProperties +// would normally allow them. +// +// Strict mode is designed for API governance scenarios where you want to +// ensure that clients only send properties that are explicitly documented +// in the OpenAPI specification, regardless of whether additionalProperties +// is set to true. +// +// # Key Features +// +// - Detects undeclared properties in request/response bodies (JSON only) +// - Detects undeclared query parameters, headers, and cookies +// - Supports ignore paths with glob patterns (e.g., "$.body.metadata.*") +// - Handles polymorphic schemas (oneOf/anyOf) via per-branch validation +// - Respects readOnly/writeOnly based on request vs response direction +// - Configurable header ignore list with sensible defaults +// +// # Known Limitations +// +// Property names containing single quotes (e.g., {"it's": "value"}) cannot be +// represented in bracket notation and cannot be matched by ignore patterns. +// Such properties will always be reported as undeclared if not in schema. +// This is acceptable because property names with quotes are extremely rare. +package strict + +import ( + "context" + "fmt" + "log/slog" + "regexp" + + "github.com/pb33f/libopenapi/datamodel/high/base" + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/pb33f/libopenapi-validator/config" +) + +// Direction indicates whether validation is for a request or response. +// This affects readOnly/writeOnly handling and Set-Cookie behavior. +type Direction int + +const ( + // DirectionRequest indicates validation of an HTTP request. + // readOnly properties are not expected in request bodies. + DirectionRequest Direction = iota + + // DirectionResponse indicates validation of an HTTP response. + // writeOnly properties are not expected in response bodies. + // Set-Cookie headers are ignored (expected in responses). + DirectionResponse +) + +// String returns a human-readable direction name. +func (d Direction) String() string { + if d == DirectionResponse { + return "response" + } + return "request" +} + +// UndeclaredValue represents a value found in data that is not declared +// in the schema. This is the core output of strict validation. +type UndeclaredValue struct { + // Path is the instance JSONPath where the undeclared value was found. + // uses bracket notation for property names with special characters. + // examples: "$.body.user.extra", "$.body['a.b'].value", "$.query.debug" + Path string + + // Name is the property, parameter, header, or cookie name. + Name string + + // Value is the actual value found (it may be truncated for display). + Value any + + // Type indicates what kind of value this is. + // one of: "property", "header", "query", "cookie", "item" + Type string + + // DeclaredProperties lists property names that ARE declared at this + // location in the schema. Helps users understand what's expected. + // for headers/query/cookies, this lists declared parameter names. + DeclaredProperties []string + + // Direction indicates whether this was in a request or response. + // used for error message disambiguation when Path is "$.body". + Direction Direction +} + +// newUndeclaredProperty creates an UndeclaredValue for an undeclared object property. +func newUndeclaredProperty(path, name string, value any, declaredNames []string, direction Direction) UndeclaredValue { + return UndeclaredValue{ + Path: path, + Name: name, + Value: TruncateValue(value), + Type: "property", + DeclaredProperties: declaredNames, + Direction: direction, + } +} + +// newUndeclaredParam creates an UndeclaredValue for an undeclared parameter (query/header/cookie). +func newUndeclaredParam(path, name string, value any, paramType string, declaredNames []string, direction Direction) UndeclaredValue { + return UndeclaredValue{ + Path: path, + Name: name, + Value: value, + Type: paramType, + DeclaredProperties: declaredNames, + Direction: direction, + } +} + +// newUndeclaredItem creates an UndeclaredValue for an undeclared array item. +func newUndeclaredItem(path, name string, value any, direction Direction) UndeclaredValue { + return UndeclaredValue{ + Path: path, + Name: name, + Value: TruncateValue(value), + Type: "item", + Direction: direction, + } +} + +// Input contains the parameters for strict validation. +type Input struct { + // Schema is the OpenAPI schema to validate against. + Schema *base.Schema + + // Data is the unmarshalled data to validate (from request/response body). + // Should be the result of json.Unmarshal. + Data any + + // Direction indicates request vs response validation. + // affects readOnly/writeOnly and Set-Cookie handling. + Direction Direction + + // Options contains validation configuration including ignore paths. + Options *config.ValidationOptions + + // BasePath is the prefix for generated instance paths. + // typically "$.body" for bodies, "$.query" for query params, etc. + BasePath string + + // Version is the OpenAPI version (3.0 or 3.1). + // affects nullable handling in schema matching. + Version float32 +} + +// Result contains the output of strict validation. +type Result struct { + Valid bool + + // UndeclaredValues lists all undeclared properties, parameters, + // headers, or cookies found during validation. + UndeclaredValues []UndeclaredValue +} + +// cycleKey uniquely identifies a schema at a specific validation path. +// Using a struct key avoids string allocation in the hot path. +type cycleKey struct { + path string + schemaKey string +} + +// traversalContext tracks state during schema traversal to detect cycles +// and limit recursion depth. +type traversalContext struct { + // visited tracks schemas already being validated at specific paths. + // key combines instance path + schema key to allow same schema at different paths. + visited map[cycleKey]bool + + // depth tracks current recursion depth for safety limits. + depth int + + // maxDepth is the maximum allowed recursion depth (default: 100). + maxDepth int + + // direction indicates request vs response for readOnly/writeOnly. + direction Direction + + // ignorePaths are compiled regex patterns for paths to skip. + ignorePaths []*regexp.Regexp + + // path is the current instance path being validated. + path string +} + +// newTraversalContext creates a new context for schema traversal. +func newTraversalContext(direction Direction, ignorePaths []*regexp.Regexp, basePath string) *traversalContext { + return &traversalContext{ + visited: make(map[cycleKey]bool), + depth: 0, + maxDepth: 100, + direction: direction, + ignorePaths: ignorePaths, + path: basePath, + } +} + +// withPath returns a new context with an updated path. +func (c *traversalContext) withPath(path string) *traversalContext { + return &traversalContext{ + visited: c.visited, + depth: c.depth + 1, + maxDepth: c.maxDepth, + direction: c.direction, + ignorePaths: c.ignorePaths, + path: path, + } +} + +// shouldIgnore checks if the current path matches any ignore pattern. +func (c *traversalContext) shouldIgnore() bool { + for _, pattern := range c.ignorePaths { + if pattern.MatchString(c.path) { + return true + } + } + return false +} + +// exceedsDepth checks if we've exceeded the maximum recursion depth. +func (c *traversalContext) exceedsDepth() bool { + return c.depth > c.maxDepth +} + +// checkAndMarkVisited checks if a schema has been visited at the current path. +// Returns true if this is a cycle (already visited), false otherwise. +// If not a cycle, marks the schema as visited. +func (c *traversalContext) checkAndMarkVisited(schemaKey string) bool { + key := cycleKey{path: c.path, schemaKey: schemaKey} + if c.visited[key] { + return true // Cycle detected + } + c.visited[key] = true + return false +} + +// Validator performs strict property validation against OpenAPI schemas. +// It detects any properties present in data that are not explicitly +// declared in the schema, regardless of additionalProperties settings. +// +// A new Validator should be created for each validation call to ensure +// isolation of internal caches and render contexts. +// +// # Cycle Detection +// +// The Validator uses two distinct cycle detection mechanisms: +// +// 1. traversalContext.visited: Tracks visited (path, schemaKey) combinations +// during the main validation traversal. This prevents infinite recursion +// when the same schema is encountered at the same instance path. The key +// uses a struct for zero-allocation lookups in the hot path. +// +// 2. renderCtx (InlineRenderContext): libopenapi's built-in cycle detection +// for schema rendering. This is used when compiling schemas for oneOf/anyOf +// variant matching. It operates at the schema reference level rather than +// instance path level. +// +// These mechanisms serve complementary purposes: visited tracks data traversal +// while renderCtx tracks schema resolution during compilation. +type Validator struct { + options *config.ValidationOptions + logger *slog.Logger + + // localCache stores compiled schemas for reuse within this validation. + // ley is schema hash (as string for map compatibility), value is compiled jsonschema. + localCache map[string]*jsonschema.Schema + + // patternCache stores compiled regex patterns for patternProperties. + // key is the pattern string, value is the compiled regex. + patternCache map[string]*regexp.Regexp + + // renderCtx is used for safe schema rendering with cycle detection. + // see Validator doc comment for how this relates to traversalContext.visited. + renderCtx *base.InlineRenderContext + + // version is the OpenAPI version (3.0 or 3.1). + version float32 + + // compiledIgnorePaths are the pre-compiled regex patterns. + compiledIgnorePaths []*regexp.Regexp +} + +// NewValidator creates a fresh validator for a single validation call. +// The validator should not be reused across concurrent requests. +// Uses the logger from options if available, otherwise logging is silent. +func NewValidator(options *config.ValidationOptions, version float32) *Validator { + var logger *slog.Logger + if options != nil && options.Logger != nil { + logger = options.Logger + } else { + // create a no-op logger that discards all output + logger = slog.New(discardHandler{}) + } + + v := &Validator{ + options: options, + logger: logger, + localCache: make(map[string]*jsonschema.Schema), + patternCache: make(map[string]*regexp.Regexp), + renderCtx: base.NewInlineRenderContext(), + version: version, + } + + if options != nil { + v.compiledIgnorePaths = compileIgnorePaths(options.StrictIgnorePaths) + } + + return v +} + +// discardHandler is a slog.Handler that discards all log records. +type discardHandler struct{} + +func (discardHandler) Enabled(context.Context, slog.Level) bool { return false } +func (discardHandler) Handle(context.Context, slog.Record) error { return nil } +func (d discardHandler) WithAttrs([]slog.Attr) slog.Handler { return d } +func (d discardHandler) WithGroup(string) slog.Handler { return d } + +// matchesIgnorePath checks if a path matches any pre-compiled ignore pattern. +func (v *Validator) matchesIgnorePath(path string) bool { + for _, pattern := range v.compiledIgnorePaths { + if pattern.MatchString(path) { + return true + } + } + return false +} + +// getCompiledPattern returns a cached compiled regex for a pattern string. +// If the pattern is not in the cache, it compiles and caches it. +// Returns nil if the pattern is invalid. +func (v *Validator) getCompiledPattern(pattern string) *regexp.Regexp { + if cached, ok := v.patternCache[pattern]; ok { + return cached + } + + compiled, err := regexp.Compile(pattern) + if err != nil { + return nil + } + + v.patternCache[pattern] = compiled + return compiled +} + +// getSchemaKey returns a unique key for a schema used in cycle detection. +// Uses the schema's low-level hash if available, otherwise the pointer address. +func (v *Validator) getSchemaKey(schema *base.Schema) string { + if schema == nil { + return "" + } + if low := schema.GoLow(); low != nil { + hash := low.Hash() + return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key + } + // fallback to pointer address for inline schemas without low-level info + return fmt.Sprintf("%p", schema) +} diff --git a/strict/utils.go b/strict/utils.go new file mode 100644 index 0000000..5bdc4c9 --- /dev/null +++ b/strict/utils.go @@ -0,0 +1,161 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "regexp" + "strconv" + "strings" +) + +// buildPath creates an instance path by appending a property name to a base path. +// Property names containing dots or brackets use bracket notation for clarity. +// +// Examples: +// - buildPath("$.body", "name") → "$.body.name" +// - buildPath("$.body", "a.b") → "$.body['a.b']" +// - buildPath("$.body", "x[0]") → "$.body['x[0]']" +func buildPath(base, propName string) string { + if needsBracketNotation(propName) { + return base + "['" + propName + "']" + } + return base + "." + propName +} + +// needsBracketNotation returns true if a property name contains characters +// that require bracket notation (dots, brackets). +func needsBracketNotation(name string) bool { + return strings.ContainsAny(name, ".[]") +} + +// buildArrayPath creates an instance path for an array element. +func buildArrayPath(base string, index int) string { + return base + "[" + strconv.Itoa(index) + "]" +} + +// compileIgnorePaths converts glob patterns to compiled regular expressions. +// Supports: +// - * matches single path segment (no dots or brackets) +// - ** matches any depth (zero or more segments) +// - [*] matches any array index +// - \* escapes literal asterisk +// - \*\* escapes literal double-asterisk +func compileIgnorePaths(patterns []string) []*regexp.Regexp { + if len(patterns) == 0 { + return nil + } + + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, pattern := range patterns { + re := compilePattern(pattern) + if re != nil { + compiled = append(compiled, re) + } + } + return compiled +} + +// compilePattern converts a single glob pattern to a regular expression. +func compilePattern(pattern string) *regexp.Regexp { + if pattern == "" { + return nil + } + + var b strings.Builder + b.WriteString("^") + + i := 0 + for i < len(pattern) { + c := pattern[i] + + // handle escape sequences + if c == '\\' && i+1 < len(pattern) { + next := pattern[i+1] + if next == '*' { + // check for escaped ** + if i+2 < len(pattern) && pattern[i+2] == '\\' && i+3 < len(pattern) && pattern[i+3] == '*' { + b.WriteString(`\*\*`) + i += 4 + continue + } + // escaped single * + b.WriteString(`\*`) + i += 2 + continue + } + // other escape - include literally + b.WriteString(regexp.QuoteMeta(string(next))) + i += 2 + continue + } + + // handle ** (any depth) + if c == '*' && i+1 < len(pattern) && pattern[i+1] == '*' { + // ** matches any sequence of segments including none + b.WriteString(`.*`) + i += 2 + continue + } + + // handle single * (single segment) + if c == '*' { + // * matches single path segment (no dots or brackets) + b.WriteString(`[^.\[\]]+`) + i++ + continue + } + + // handle [*] (any array index) + if c == '[' && i+2 < len(pattern) && pattern[i+1] == '*' && pattern[i+2] == ']' { + b.WriteString(`\[\d+\]`) + i += 3 + continue + } + + // handle special regex characters + switch c { + case '.', '[', ']', '(', ')', '{', '}', '+', '?', '^', '$', '|': + b.WriteString(`\`) + b.WriteByte(c) + default: + b.WriteByte(c) + } + i++ + } + + b.WriteString("$") + + re, _ := regexp.Compile(b.String()) + return re +} + +// TruncateValue creates a display-friendly version of a value. +// Long strings are truncated, complex objects show type info. +// This is exported for use in error messages. +func TruncateValue(v any) any { + switch val := v.(type) { + case string: + if len(val) > 50 { + return val[:47] + "..." + } + return val + case map[string]any: + if len(val) > 3 { + return "{...}" + } + return val + case []any: + if len(val) > 3 { + return "[...]" + } + return val + default: + return v + } +} + +// truncateValue is an internal alias for TruncateValue. +func truncateValue(v any) any { + return TruncateValue(v) +} diff --git a/strict/utils_test.go b/strict/utils_test.go new file mode 100644 index 0000000..cdf4be1 --- /dev/null +++ b/strict/utils_test.go @@ -0,0 +1,134 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCompilePattern_EscapedDoubleAsterisk(t *testing.T) { + // Pattern \*\* should match literal "**" in path (lines 78-80) + // This escapes the glob ** so it matches the literal string ** + re := compilePattern(`$.body.\*\*`) + assert.NotNil(t, re) + + // Should match literal ** in path + assert.True(t, re.MatchString("$.body.**")) + + // Should NOT match arbitrary depth (that's what unescaped ** does) + assert.False(t, re.MatchString("$.body.foo.bar")) +} + +func TestCompilePattern_EscapedNonAsterisk(t *testing.T) { + // Pattern with escaped character that's not * (lines 88-90) + // \n should match literal 'n', \. should match literal '.' + re := compilePattern(`$.body\nvalue`) + assert.NotNil(t, re) + + // Should match with literal 'n' (the escape just includes the next char) + assert.True(t, re.MatchString("$.bodynvalue")) +} + +func TestCompilePattern_EscapedDot(t *testing.T) { + // Escaped dot should be literal dot + re := compilePattern(`$.body\.name`) + assert.NotNil(t, re) + + // Should match path with literal dot + assert.True(t, re.MatchString("$.body.name")) +} + +func TestCompilePattern_Empty(t *testing.T) { + // Empty pattern returns nil + re := compilePattern("") + assert.Nil(t, re) +} + +func TestBuildPath_WithDot(t *testing.T) { + // Property with dot uses bracket notation + result := buildPath("$.body", "a.b") + assert.Equal(t, "$.body['a.b']", result) +} + +func TestBuildPath_WithBrackets(t *testing.T) { + // Property with brackets uses bracket notation + result := buildPath("$.body", "x[0]") + assert.Equal(t, "$.body['x[0]']", result) +} + +func TestBuildPath_Simple(t *testing.T) { + // Simple property uses dot notation + result := buildPath("$.body", "name") + assert.Equal(t, "$.body.name", result) +} + +func TestBuildArrayPath(t *testing.T) { + result := buildArrayPath("$.body.items", 5) + assert.Equal(t, "$.body.items[5]", result) +} + +func TestCompileIgnorePaths_Empty(t *testing.T) { + result := compileIgnorePaths(nil) + assert.Nil(t, result) + + result = compileIgnorePaths([]string{}) + assert.Nil(t, result) +} + +func TestCompileIgnorePaths_WithPatterns(t *testing.T) { + patterns := []string{ + "$.body.metadata", + "$.body.items[*].internal", + } + result := compileIgnorePaths(patterns) + assert.Len(t, result, 2) +} + +func TestTruncateValue_LongString(t *testing.T) { + // String > 50 chars gets truncated + longStr := "this is a very long string that exceeds fifty characters in length" + result := TruncateValue(longStr) + assert.Equal(t, "this is a very long string that exceeds fifty c...", result) +} + +func TestTruncateValue_ShortString(t *testing.T) { + shortStr := "short" + result := TruncateValue(shortStr) + assert.Equal(t, "short", result) +} + +func TestTruncateValue_LargeMap(t *testing.T) { + // Map with > 3 keys shows {...} + m := map[string]any{"a": 1, "b": 2, "c": 3, "d": 4} + result := TruncateValue(m) + assert.Equal(t, "{...}", result) +} + +func TestTruncateValue_SmallMap(t *testing.T) { + m := map[string]any{"a": 1, "b": 2} + result := TruncateValue(m) + assert.Equal(t, m, result) +} + +func TestTruncateValue_LargeSlice(t *testing.T) { + // Slice with > 3 elements shows [...] + s := []any{1, 2, 3, 4} + result := TruncateValue(s) + assert.Equal(t, "[...]", result) +} + +func TestTruncateValue_SmallSlice(t *testing.T) { + s := []any{1, 2} + result := TruncateValue(s) + assert.Equal(t, s, result) +} + +func TestTruncateValue_OtherTypes(t *testing.T) { + // Other types returned as-is + assert.Equal(t, 42, TruncateValue(42)) + assert.Equal(t, true, TruncateValue(true)) + assert.Equal(t, 3.14, TruncateValue(3.14)) +} diff --git a/strict/validator.go b/strict/validator.go new file mode 100644 index 0000000..bc7a53d --- /dev/null +++ b/strict/validator.go @@ -0,0 +1,247 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "net/http" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + + "github.com/pb33f/libopenapi-validator/config" +) + +// Validate performs strict validation on the input data against the schema. +// This is the main entry point for body validation. +// +// It detects undeclared properties even when additionalProperties: true +// would normally allow them. This is useful for API governance scenarios +// where you want to ensure clients only send explicitly documented properties. +func (v *Validator) Validate(input Input) *Result { + result := &Result{Valid: true} + + if input.Schema == nil || input.Data == nil { + return result + } + + ctx := newTraversalContext(input.Direction, v.compiledIgnorePaths, input.BasePath) + + undeclared := v.validateValue(ctx, input.Schema, input.Data) + + if len(undeclared) > 0 { + result.Valid = false + result.UndeclaredValues = undeclared + } + + return result +} + +// ValidateBody is a convenience method for validating request/response bodies. +func ValidateBody(schema *base.Schema, data any, direction Direction, options *config.ValidationOptions, version float32) *Result { + v := NewValidator(options, version) + return v.Validate(Input{ + Schema: schema, + Data: data, + Direction: direction, + Options: options, + BasePath: "$.body", + Version: version, + }) +} + +// ValidateQueryParams checks for undeclared query parameters in an HTTP request. +// It compares the query parameters present in the request against those +// declared in the OpenAPI operation. +func ValidateQueryParams( + request *http.Request, + declaredParams []*v3.Parameter, + options *config.ValidationOptions, +) []UndeclaredValue { + if request == nil || options == nil || !options.StrictMode { + return nil + } + + v := NewValidator(options, 3.2) + + // build set of declared query params (case-sensitive) + declared := make(map[string]bool) + for _, param := range declaredParams { + if param.In == "query" { + declared[param.Name] = true + } + } + + var undeclared []UndeclaredValue + + // check each query parameter in the request + for paramName := range request.URL.Query() { + if !declared[paramName] { + // build path using proper notation for special characters + path := buildPath("$.query", paramName) + if v.matchesIgnorePath(path) { + continue + } + + undeclared = append(undeclared, + newUndeclaredParam(path, paramName, request.URL.Query().Get(paramName), "query", getParamNames(declaredParams, "query"), DirectionRequest)) + } + } + + return undeclared +} + +// ValidateRequestHeaders checks for undeclared headers in an HTTP request. +// Header names are normalized to lowercase for path generation and pattern matching. +func ValidateRequestHeaders( + headers http.Header, + declaredParams []*v3.Parameter, + options *config.ValidationOptions, +) []UndeclaredValue { + if headers == nil || options == nil || !options.StrictMode { + return nil + } + + v := NewValidator(options, 3.2) + + // build set of declared headers (case-insensitive) + declared := make(map[string]bool) + for _, param := range declaredParams { + if param.In == "header" { + declared[strings.ToLower(param.Name)] = true + } + } + + var undeclared []UndeclaredValue + + // check each header + for headerName := range headers { + lowerName := strings.ToLower(headerName) + + // skip if declared + if declared[lowerName] { + continue + } + + // skip if in ignored headers list + if v.isHeaderIgnored(headerName, DirectionRequest) { + continue + } + + // build path using lowercase name for case-insensitive pattern matching + path := buildPath("$.headers", lowerName) + if v.matchesIgnorePath(path) { + continue + } + + undeclared = append(undeclared, + newUndeclaredParam(path, headerName, headers.Get(headerName), "header", getParamNames(declaredParams, "header"), DirectionRequest)) + } + + return undeclared +} + +// ValidateCookies checks for undeclared cookies in an HTTP request. +func ValidateCookies( + request *http.Request, + declaredParams []*v3.Parameter, + options *config.ValidationOptions, +) []UndeclaredValue { + if request == nil || options == nil || !options.StrictMode { + return nil + } + + v := NewValidator(options, 3.2) + + // build set of declared cookies + declared := make(map[string]bool) + for _, param := range declaredParams { + if param.In == "cookie" { + declared[param.Name] = true + } + } + + var undeclared []UndeclaredValue + + // check each cookie in the request + for _, cookie := range request.Cookies() { + if !declared[cookie.Name] { + // build path using proper notation for special characters + path := buildPath("$.cookies", cookie.Name) + if v.matchesIgnorePath(path) { + continue + } + + undeclared = append(undeclared, + newUndeclaredParam(path, cookie.Name, cookie.Value, "cookie", getParamNames(declaredParams, "cookie"), DirectionRequest)) + } + } + + return undeclared +} + +// getParamNames extracts parameter names of a specific type. +func getParamNames(params []*v3.Parameter, paramType string) []string { + var names []string + for _, param := range params { + if param.In == paramType { + names = append(names, param.Name) + } + } + return names +} + +// ValidateResponseHeaders checks for undeclared headers in an HTTP response. +// Uses the declared headers from the OpenAPI response object. +// Header names are normalized to lowercase for path generation and pattern matching. +func ValidateResponseHeaders( + headers http.Header, + declaredHeaders *map[string]*v3.Header, + options *config.ValidationOptions, +) []UndeclaredValue { + if headers == nil || options == nil || !options.StrictMode { + return nil + } + + v := NewValidator(options, 3.2) + + // build set of declared headers (case-insensitive) + declared := make(map[string]bool) + if declaredHeaders != nil { + for name := range *declaredHeaders { + declared[strings.ToLower(name)] = true + } + } + + var undeclared []UndeclaredValue + var declaredNames []string + if declaredHeaders != nil { + for name := range *declaredHeaders { + declaredNames = append(declaredNames, name) + } + } + + for headerName := range headers { + lowerName := strings.ToLower(headerName) + + if declared[lowerName] { + continue + } + + if v.isHeaderIgnored(headerName, DirectionResponse) { + continue + } + + // build path using lowercase name for case-insensitive pattern matching + path := buildPath("$.headers", lowerName) + if v.matchesIgnorePath(path) { + continue + } + + undeclared = append(undeclared, + newUndeclaredParam(path, headerName, headers.Get(headerName), "header", declaredNames, DirectionResponse)) + } + + return undeclared +} diff --git a/strict/validator_test.go b/strict/validator_test.go new file mode 100644 index 0000000..64c44f0 --- /dev/null +++ b/strict/validator_test.go @@ -0,0 +1,6057 @@ +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package strict + +import ( + "context" + "log/slog" + "net/http" + "testing" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + libcache "github.com/pb33f/libopenapi-validator/cache" + "github.com/pb33f/libopenapi-validator/config" + "github.com/pb33f/libopenapi-validator/helpers" +) + +// Helper to build a schema from YAML +func buildSchemaFromYAML(t *testing.T, yml string) *libopenapi.DocumentModel[v3.Document] { + doc, err := libopenapi.NewDocument([]byte(yml)) + require.NoError(t, err) + model, errs := doc.BuildV3Model() + require.Empty(t, errs) + return model +} + +// Helper to get schema +func getSchema(t *testing.T, model *libopenapi.DocumentModel[v3.Document], name string) *base.Schema { + schemaProxy := model.Model.Components.Schemas.GetOrZero(name) + require.NotNil(t, schemaProxy) + schema := schemaProxy.Schema() + require.NotNil(t, schema) + return schema +} + +func TestStrictValidator_SimpleUndeclaredProperty(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + age: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared property + data := map[string]any{ + "name": "John", + "age": 30, + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_AllPropertiesDeclared(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + age: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with only declared properties + data := map[string]any{ + "name": "John", + "age": 30, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_NestedObjects(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + address: + type: object + properties: + street: + type: string + city: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared nested property + data := map[string]any{ + "name": "John", + "address": map[string]any{ + "street": "123 Main St", + "city": "Anytown", + "zipcode": "12345", // undeclared + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "zipcode", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.address.zipcode", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_ArrayOfObjects(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Users: + type: array + items: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Users") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared property in array item + data := []any{ + map[string]any{ + "name": "John", + "extra": "undeclared", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_IgnorePaths(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.metadata.*"), + ) + v := NewValidator(opts, 3.1) + + // Test that ignored path is not reported + data := map[string]any{ + "name": "John", + "metadata": map[string]any{ + "custom": "value", // Should be ignored + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // metadata itself is undeclared, but its children should be ignored + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "metadata", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_AdditionalPropertiesFalse(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + additionalProperties: false + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // additionalProperties: false means base validation catches this + // strict mode should NOT report (would be redundant) + data := map[string]any{ + "name": "John", + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // strict should NOT report this since additionalProperties: false + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AdditionalPropertiesWithSchema(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + additionalProperties: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // additionalProperties with schema means extra properties are allowed + // but strict should still report them (they're not in explicit schema) + data := map[string]any{ + "name": "John", + "extra": "valid string", // Matches additionalProperties schema + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // strict should report "extra" as undeclared even though it's valid + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_PatternProperties(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Config: + type: object + properties: + name: + type: string + patternProperties: + "^x-.*$": + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Config") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Properties matching patternProperties should be considered declared + data := map[string]any{ + "name": "myconfig", + "x-custom": "extension value", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // x-custom matches the pattern, so it should be considered declared + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestBuildPath(t *testing.T) { + tests := []struct { + base string + propName string + expected string + }{ + {"$.body", "name", "$.body.name"}, + {"$.body", "a.b", "$.body['a.b']"}, + {"$.body", "x[0]", "$.body['x[0]']"}, + {"$.body.user", "email", "$.body.user.email"}, + } + + for _, tt := range tests { + t.Run(tt.propName, func(t *testing.T) { + result := buildPath(tt.base, tt.propName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCompilePattern(t *testing.T) { + tests := []struct { + pattern string + input string + matches bool + }{ + // Single segment wildcard + {"$.body.metadata.*", "$.body.metadata.custom", true}, + {"$.body.metadata.*", "$.body.metadata.custom.nested", false}, + + // Double wildcard (any depth) + {"$.body.**", "$.body.a.b.c", true}, + {"$.body.**.x-*", "$.body.deep.nested.x-custom", true}, + + // Array index wildcard + {"$.body.items[*].name", "$.body.items[0].name", true}, + {"$.body.items[*].name", "$.body.items[999].name", true}, + + // Escaped asterisk + {"$.body.\\*", "$.body.*", true}, + {"$.body.\\*", "$.body.anything", false}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + re := compilePattern(tt.pattern) + if re == nil { + t.Fatalf("Failed to compile pattern: %s", tt.pattern) + } + result := re.MatchString(tt.input) + assert.Equal(t, tt.matches, result, "Pattern: %s, Input: %s", tt.pattern, tt.input) + }) + } +} + +func TestDirection_String(t *testing.T) { + assert.Equal(t, "request", DirectionRequest.String()) + assert.Equal(t, "response", DirectionResponse.String()) +} + +func TestIsHeaderIgnored(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Standard headers should be ignored + assert.True(t, v.isHeaderIgnored("Content-Type", DirectionRequest)) + assert.True(t, v.isHeaderIgnored("content-type", DirectionRequest)) + assert.True(t, v.isHeaderIgnored("Authorization", DirectionRequest)) + + // Set-Cookie is direction-aware + assert.True(t, v.isHeaderIgnored("Set-Cookie", DirectionResponse)) + assert.False(t, v.isHeaderIgnored("Set-Cookie", DirectionRequest)) + + // Custom headers should not be ignored + assert.False(t, v.isHeaderIgnored("X-Custom-Header", DirectionRequest)) +} + +func TestWithStrictIgnoredHeaders(t *testing.T) { + // Replace defaults entirely + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnoredHeaders("X-Only-This"), + ) + v := NewValidator(opts, 3.1) + + // Standard headers are NOT ignored anymore + assert.False(t, v.isHeaderIgnored("Content-Type", DirectionRequest)) + + // Only our custom header is ignored + assert.True(t, v.isHeaderIgnored("X-Only-This", DirectionRequest)) +} + +func TestWithStrictIgnoredHeadersExtra(t *testing.T) { + // Add to defaults + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnoredHeadersExtra("X-Custom-Extra"), + ) + v := NewValidator(opts, 3.1) + + // Standard headers are still ignored + assert.True(t, v.isHeaderIgnored("Content-Type", DirectionRequest)) + + // Our custom header is also ignored + assert.True(t, v.isHeaderIgnored("X-Custom-Extra", DirectionRequest)) +} + +func TestTruncateValue(t *testing.T) { + // Short string unchanged + assert.Equal(t, "hello", truncateValue("hello")) + + // Long string truncated + longStr := "this is a very long string that should be truncated" + result := truncateValue(longStr).(string) + assert.True(t, len(result) <= 50) + assert.Contains(t, result, "...") + + // Map truncated + bigMap := map[string]any{"a": 1, "b": 2, "c": 3, "d": 4} + assert.Equal(t, "{...}", truncateValue(bigMap)) + + // Slice truncated + bigSlice := []any{1, 2, 3, 4} + assert.Equal(t, "[...]", truncateValue(bigSlice)) +} + +func TestStrictValidator_PolymorphicPatternProperties(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Edge + version: "1.0" +paths: {} +components: + schemas: + VariantA: + type: object + required: + - kind + properties: + kind: + type: string + aProp: + type: string + Root: + type: object + discriminator: + propertyName: kind + oneOf: + - $ref: "#/components/schemas/VariantA" + patternProperties: + "^x-.*$": + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Root") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "kind": "VariantA", + "aProp": "ok", + "x-foo": map[string]any{ + "id": "1", + "extra": "nope", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + require.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.x-foo.extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_ReusedSchemaDifferentPaths(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Edge + version: "1.0" +paths: {} +components: + schemas: + Node: + type: object + properties: + id: + type: string + Root: + type: object + properties: + left: + $ref: "#/components/schemas/Node" + right: + $ref: "#/components/schemas/Node" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Root") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "left": map[string]any{ + "id": "1", + "extra": "nope", + }, + "right": map[string]any{ + "id": "2", + "extra": "nope", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + require.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 2) +} + +func TestStrictValidator_UnevaluatedItemsOnly(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Edge + version: "1.0" +paths: {} +components: + schemas: + Items: + type: array + unevaluatedItems: + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Items") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := []any{ + map[string]any{ + "id": "1", + "extra": "nope", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + require.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "$.body[0].extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_HeaderIgnorePathsCase(t *testing.T) { + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.headers.x-trace"), + ) + + headers := http.Header{ + "X-Trace": {"abc"}, + } + + undeclared := ValidateRequestHeaders(headers, nil, opts) + assert.Empty(t, undeclared) +} + +func TestStrictValidator_OneOfWithParentProperties(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + oneOf: + - properties: + name: + type: string + - properties: + title: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with parent property "id" + oneOf variant property "name" + // Both should be considered declared + data := map[string]any{ + "id": "123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // id is from parent, name is from oneOf variant - both should be declared + assert.True(t, result.Valid, "Parent + oneOf variant properties should be valid") + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AnyOfWithParentProperties(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + anyOf: + - properties: + name: + type: string + - properties: + title: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with parent property "id" + anyOf variant property "name" + data := map[string]any{ + "id": "123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // id is from parent, name is from anyOf variant - both should be declared + assert.True(t, result.Valid, "Parent + anyOf variant properties should be valid") + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_OneOfWithUndeclaredProperty(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + oneOf: + - properties: + name: + type: string + - properties: + title: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared property "extra" + data := map[string]any{ + "id": "123", + "name": "John", + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // "extra" is not in parent or variant - should be reported as undeclared + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_PatternPropertiesWithAdditionalPropertiesFalse(t *testing.T) { + // This tests that patternProperties are recursed into even when + // additionalProperties: false (which short-circuits to recurseIntoDeclaredProperties) + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Config: + type: object + additionalProperties: false + properties: + name: + type: string + patternProperties: + "^x-": + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Config") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with pattern property that has undeclared nested property + data := map[string]any{ + "name": "test", + "x-custom": map[string]any{ + "id": "123", + "extra": "undeclared nested field", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // "extra" inside x-custom should be reported as undeclared + // This verifies patternProperties are recursed into even with additionalProperties: false + assert.False(t, result.Valid, "Should report undeclared nested property in patternProperties") + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.x-custom.extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_PatternPropertiesInOneOf(t *testing.T) { + // This tests that patternProperties in oneOf/anyOf variants are recursed into + // to find undeclared properties in nested objects. + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Container: + type: object + properties: + type: + type: string + oneOf: + - properties: + type: + const: "dynamic" + patternProperties: + "^x-": + type: object + properties: + value: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Container") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared property inside pattern-matched nested object + data := map[string]any{ + "type": "dynamic", + "x-custom": map[string]any{ + "value": "hello", + "undeclared": "should be caught", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // "undeclared" inside x-custom should be reported + assert.False(t, result.Valid, "Should report undeclared property in pattern-matched object") + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "undeclared", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.x-custom.undeclared", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_CycleDetection(t *testing.T) { + // This tests that circular schema references don't cause infinite recursion. + // The cycle detection should stop validation of the same schema at the same path. + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Node: + type: object + properties: + name: + type: string + child: + $ref: "#/components/schemas/Node" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Node") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with deeply nested data that reuses the same schema + data := map[string]any{ + "name": "root", + "child": map[string]any{ + "name": "level1", + "child": map[string]any{ + "name": "level2", + "extra": "undeclared at level2", + }, + }, + "extra": "undeclared at root", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should find undeclared properties at multiple levels + assert.False(t, result.Valid) + assert.GreaterOrEqual(t, len(result.UndeclaredValues), 2, "Should find undeclared at multiple levels") + + // Verify both undeclared properties were found + var foundRoot, foundLevel2 bool + for _, u := range result.UndeclaredValues { + if u.Path == "$.body.extra" { + foundRoot = true + } + if u.Path == "$.body.child.child.extra" { + foundLevel2 = true + } + } + assert.True(t, foundRoot, "Should find undeclared at root level") + assert.True(t, foundLevel2, "Should find undeclared at nested level") +} + +func TestStrictValidator_CycleDetectionDoesNotBlockDifferentPaths(t *testing.T) { + // Tests that the same schema can be validated at different paths. + // Cycle detection uses path+schemaRef, so same schema at different paths should work. + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Container: + type: object + properties: + left: + $ref: "#/components/schemas/Item" + right: + $ref: "#/components/schemas/Item" + Item: + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Container") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with undeclared properties in both left and right + data := map[string]any{ + "left": map[string]any{ + "id": "1", + "extraLeft": "undeclared in left", + }, + "right": map[string]any{ + "id": "2", + "extraRight": "undeclared in right", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should find undeclared in both left and right + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 2, "Should find undeclared in both branches") + + var foundLeft, foundRight bool + for _, u := range result.UndeclaredValues { + if u.Name == "extraLeft" { + foundLeft = true + } + if u.Name == "extraRight" { + foundRight = true + } + } + assert.True(t, foundLeft, "Should find undeclared in left branch") + assert.True(t, foundRight, "Should find undeclared in right branch") +} + +func TestValidateBody_UndeclaredProperty(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + age: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + + data := map[string]any{ + "name": "John", + "age": 30, + "extra": "undeclared", + } + + result := ValidateBody(schema, data, DirectionRequest, opts, 3.1) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.extra", result.UndeclaredValues[0].Path) +} + +func TestValidateBody_AllPropertiesDeclared(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + age: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + + data := map[string]any{ + "name": "John", + "age": 30, + } + + result := ValidateBody(schema, data, DirectionResponse, opts, 3.1) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestValidateBody_NilInputs(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + // nil schema + result := ValidateBody(nil, map[string]any{"foo": "bar"}, DirectionRequest, opts, 3.1) + assert.True(t, result.Valid) + + // nil data + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + result = ValidateBody(schema, nil, DirectionRequest, opts, 3.1) + assert.True(t, result.Valid) +} + +// ============== allOf tests ============== + +func TestStrictValidator_AllOf_Simple(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Base: + type: object + properties: + id: + type: string + Extended: + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Extended") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Both id (from Base) and name (from inline) should be declared + data := map[string]any{ + "id": "123", + "name": "Test", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AllOf_WithUndeclared(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Base: + type: object + properties: + id: + type: string + Extended: + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Extended") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // extra is not in any allOf schema + data := map[string]any{ + "id": "123", + "name": "Test", + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_AllOf_WithAdditionalPropertiesFalse(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Extended: + allOf: + - type: object + additionalProperties: false + properties: + id: + type: string + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Extended") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // When any allOf has additionalProperties: false, skip strict + data := map[string]any{ + "id": "123", + "name": "Test", + "extra": "would normally be undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // additionalProperties: false means base validation handles this + assert.True(t, result.Valid) +} + +func TestStrictValidator_AllOf_WithNestedObjects(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Address: + type: object + properties: + street: + type: string + Extended: + allOf: + - type: object + properties: + id: + type: string + - type: object + properties: + address: + $ref: "#/components/schemas/Address" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Extended") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Undeclared nested property in address + data := map[string]any{ + "id": "123", + "address": map[string]any{ + "street": "Main St", + "zipcode": "12345", // undeclared in Address + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "zipcode", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.address.zipcode", result.UndeclaredValues[0].Path) +} + +// ============== Parameter validation tests ============== + +func TestValidateQueryParams_Basic(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + params := []*v3.Parameter{ + {Name: "limit", In: "query"}, + {Name: "offset", In: "query"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test?limit=10&offset=0&extra=undeclared", nil) + + undeclared := ValidateQueryParams(req, params, opts) + + assert.Len(t, undeclared, 1) + assert.Equal(t, "extra", undeclared[0].Name) + assert.Equal(t, "$.query.extra", undeclared[0].Path) + assert.Equal(t, "query", undeclared[0].Type) +} + +func TestValidateQueryParams_AllDeclared(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + params := []*v3.Parameter{ + {Name: "limit", In: "query"}, + {Name: "offset", In: "query"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test?limit=10&offset=0", nil) + + undeclared := ValidateQueryParams(req, params, opts) + + assert.Empty(t, undeclared) +} + +func TestValidateQueryParams_IgnorePaths(t *testing.T) { + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.query.debug"), + ) + + params := []*v3.Parameter{ + {Name: "limit", In: "query"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test?limit=10&debug=true", nil) + + undeclared := ValidateQueryParams(req, params, opts) + + assert.Empty(t, undeclared) +} + +func TestValidateQueryParams_NilInputs(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + // nil request + assert.Nil(t, ValidateQueryParams(nil, nil, opts)) + + // nil options + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + assert.Nil(t, ValidateQueryParams(req, nil, nil)) + + // strict mode disabled + optsNoStrict := config.NewValidationOptions() + assert.Nil(t, ValidateQueryParams(req, nil, optsNoStrict)) +} + +func TestValidateCookies_Basic(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + params := []*v3.Parameter{ + {Name: "session", In: "cookie"}, + {Name: "token", In: "cookie"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"}) + req.AddCookie(&http.Cookie{Name: "token", Value: "xyz789"}) + req.AddCookie(&http.Cookie{Name: "tracking", Value: "undeclared"}) + + undeclared := ValidateCookies(req, params, opts) + + assert.Len(t, undeclared, 1) + assert.Equal(t, "tracking", undeclared[0].Name) + assert.Equal(t, "$.cookies.tracking", undeclared[0].Path) + assert.Equal(t, "cookie", undeclared[0].Type) +} + +func TestValidateCookies_AllDeclared(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + params := []*v3.Parameter{ + {Name: "session", In: "cookie"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"}) + + undeclared := ValidateCookies(req, params, opts) + + assert.Empty(t, undeclared) +} + +func TestValidateCookies_IgnorePaths(t *testing.T) { + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.cookies.tracking"), + ) + + params := []*v3.Parameter{ + {Name: "session", In: "cookie"}, + } + + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "abc123"}) + req.AddCookie(&http.Cookie{Name: "tracking", Value: "ignored"}) + + undeclared := ValidateCookies(req, params, opts) + + assert.Empty(t, undeclared) +} + +func TestValidateCookies_NilInputs(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + // nil request + assert.Nil(t, ValidateCookies(nil, nil, opts)) + + // nil options + req, _ := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + assert.Nil(t, ValidateCookies(req, nil, nil)) + + // strict mode disabled + optsNoStrict := config.NewValidationOptions() + assert.Nil(t, ValidateCookies(req, nil, optsNoStrict)) +} + +func TestValidateResponseHeaders_Basic(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + declaredHeaders := &map[string]*v3.Header{ + "X-Request-Id": {}, + "X-Rate-Limit": {}, + } + + headers := http.Header{ + "X-Request-Id": {"abc123"}, + "X-Rate-Limit": {"100"}, + "X-Custom-Header": {"undeclared"}, + } + + undeclared := ValidateResponseHeaders(headers, declaredHeaders, opts) + + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Custom-Header", undeclared[0].Name) + assert.Equal(t, "$.headers.x-custom-header", undeclared[0].Path) + assert.Equal(t, DirectionResponse, undeclared[0].Direction) +} + +func TestValidateResponseHeaders_AllDeclared(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + declaredHeaders := &map[string]*v3.Header{ + "X-Request-Id": {}, + } + + headers := http.Header{ + "X-Request-Id": {"abc123"}, + } + + undeclared := ValidateResponseHeaders(headers, declaredHeaders, opts) + + assert.Empty(t, undeclared) +} + +func TestValidateResponseHeaders_SetCookieIgnored(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + // No declared headers + var declaredHeaders *map[string]*v3.Header + + headers := http.Header{ + "Set-Cookie": {"session=abc123"}, + } + + undeclared := ValidateResponseHeaders(headers, declaredHeaders, opts) + + // Set-Cookie should be ignored in responses + assert.Empty(t, undeclared) +} + +func TestValidateResponseHeaders_NilInputs(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + + // nil headers + assert.Nil(t, ValidateResponseHeaders(nil, nil, opts)) + + // nil options + headers := http.Header{"X-Test": {"value"}} + assert.Nil(t, ValidateResponseHeaders(headers, nil, nil)) + + // strict mode disabled + optsNoStrict := config.NewValidationOptions() + assert.Nil(t, ValidateResponseHeaders(headers, nil, optsNoStrict)) +} + +// ============== Array validation tests ============== + +func TestStrictValidator_ArrayItemsFalse(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Empty: + type: array + items: false +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Empty") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // items: false means no items allowed + data := []any{"item1", "item2"} + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should report both items as undeclared + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 2) +} + +func TestStrictValidator_PrefixItems(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Tuple: + type: array + prefixItems: + - type: object + properties: + first: + type: string + - type: object + properties: + second: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Tuple") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Each prefix item has its own schema + data := []any{ + map[string]any{"first": "a", "extra1": "undeclared"}, + map[string]any{"second": "b", "extra2": "undeclared"}, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 2) +} + +func TestStrictValidator_PrefixItems_FewerDataElements(t *testing.T) { + // Covers array_validator.go:41-42 - break when data has fewer elements than prefixItems + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Tuple: + type: array + prefixItems: + - type: object + properties: + first: + type: string + - type: object + properties: + second: + type: string + - type: object + properties: + third: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Tuple") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Only 1 data element, but 3 prefixItems - should break early at line 42 + data := []any{ + map[string]any{"first": "a", "extra": "undeclared"}, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Only first element validated, has one undeclared property + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_PrefixItemsWithItems(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Tuple: + type: array + prefixItems: + - type: object + properties: + first: + type: string + items: + type: object + properties: + rest: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Tuple") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // First item uses prefixItems[0], rest use items schema + data := []any{ + map[string]any{"first": "a"}, + map[string]any{"rest": "b"}, + map[string]any{"rest": "c", "extra": "undeclared"}, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body[2].extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_EmptyArray(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Items: + type: array + items: + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Items") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Empty array should be valid + data := []any{} + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +// ============== Additional edge case tests ============== + +func TestStrictValidator_ReadOnlyInRequest(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // readOnly properties should not be expected in requests + data := map[string]any{ + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) +} + +func TestStrictValidator_WriteOnlyInResponse(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // writeOnly properties should not be expected in responses + data := map[string]any{ + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) +} + +func TestStrictValidator_DiscriminatorMapping(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Dog: + type: object + properties: + petType: + type: string + bark: + type: string + Cat: + type: object + properties: + petType: + type: string + meow: + type: string + Pet: + type: object + discriminator: + propertyName: petType + mapping: + dog: "#/components/schemas/Dog" + cat: "#/components/schemas/Cat" + oneOf: + - $ref: "#/components/schemas/Dog" + - $ref: "#/components/schemas/Cat" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with discriminator selecting Dog + data := map[string]any{ + "petType": "dog", + "bark": "woof", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_NilSchemaData(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // nil schema + result := v.Validate(Input{ + Schema: nil, + Data: map[string]any{"foo": "bar"}, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.True(t, result.Valid) + + // nil data + result = v.Validate(Input{ + Schema: &base.Schema{}, + Data: nil, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.True(t, result.Valid) +} + +func TestNewValidator_NilOptions(t *testing.T) { + v := NewValidator(nil, 3.1) + assert.NotNil(t, v) + assert.NotNil(t, v.logger) +} + +func TestGetSchemaKey_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + key := v.getSchemaKey(nil) + assert.Equal(t, "", key) +} + +func TestGetCompiledPattern_Invalid(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Invalid regex pattern + pattern := v.getCompiledPattern("[invalid") + assert.Nil(t, pattern) +} + +func TestGetCompiledPattern_Cached(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // First call compiles + pattern1 := v.getCompiledPattern("^test$") + assert.NotNil(t, pattern1) + + // Second call returns cached + pattern2 := v.getCompiledPattern("^test$") + assert.Equal(t, pattern1, pattern2) +} + +func TestExceedsDepth(t *testing.T) { + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + assert.False(t, ctx.exceedsDepth()) + + // Create context at max depth + for i := 0; i < 101; i++ { + ctx = ctx.withPath("$.body.deep") + } + assert.True(t, ctx.exceedsDepth()) +} + +func TestCheckAndMarkVisited_Cycle(t *testing.T) { + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + + // First visit should return false (not a cycle) + isCycle := ctx.checkAndMarkVisited("schema1") + assert.False(t, isCycle) + + // Second visit to same schema at same path should return true (cycle) + isCycle = ctx.checkAndMarkVisited("schema1") + assert.True(t, isCycle) +} + +func TestGetParamNames(t *testing.T) { + params := []*v3.Parameter{ + {Name: "limit", In: "query"}, + {Name: "offset", In: "query"}, + {Name: "X-Api-Key", In: "header"}, + } + + queryNames := getParamNames(params, "query") + assert.ElementsMatch(t, []string{"limit", "offset"}, queryNames) + + headerNames := getParamNames(params, "header") + assert.ElementsMatch(t, []string{"X-Api-Key"}, headerNames) + + cookieNames := getParamNames(params, "cookie") + assert.Empty(t, cookieNames) +} + +func TestGetEffectiveIgnoredHeaders_Nil(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + headers := v.getEffectiveIgnoredHeaders() + assert.NotEmpty(t, headers) + assert.Contains(t, headers, "content-type") +} + +func TestStrictValidator_DependentSchemas(t *testing.T) { + // Test dependentSchemas with trigger property present + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + CreditCard: + type: object + properties: + name: + type: string + creditCard: + type: string + dependentSchemas: + creditCard: + properties: + billingAddress: + type: string + required: + - billingAddress +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "CreditCard") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // When creditCard is present, billingAddress becomes a declared property + data := map[string]any{ + "name": "John", + "creditCard": "1234-5678-9012-3456", + "billingAddress": "123 Main St", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_DependentSchemas_NoTrigger(t *testing.T) { + // Test dependentSchemas when trigger property is NOT present + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + CreditCard: + type: object + properties: + name: + type: string + creditCard: + type: string + dependentSchemas: + creditCard: + properties: + billingAddress: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "CreditCard") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // When creditCard is NOT present, billingAddress is undeclared + data := map[string]any{ + "name": "John", + "billingAddress": "123 Main St", // undeclared without creditCard trigger + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "billingAddress", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_IfThenElse_ThenBranch(t *testing.T) { + // Test if/then/else - matching if condition uses then properties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Conditional: + type: object + properties: + type: + type: string + if: + properties: + type: + const: "car" + then: + properties: + numWheels: + type: integer + else: + properties: + numLegs: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Conditional") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // type="car" matches if condition, so numWheels is declared + data := map[string]any{ + "type": "car", + "numWheels": 4, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_IfThenElse_ElseBranch(t *testing.T) { + // Test if/then/else - non-matching if condition uses else properties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Conditional: + type: object + properties: + type: + type: string + if: + properties: + type: + const: "car" + then: + properties: + numWheels: + type: integer + else: + properties: + numLegs: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Conditional") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // type="animal" does NOT match if condition, so numLegs is declared (else branch) + data := map[string]any{ + "type": "animal", + "numLegs": 4, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_IfThenElse_WrongBranchProperty(t *testing.T) { + // Test if/then/else - using wrong branch property is undeclared + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Conditional: + type: object + properties: + type: + type: string + if: + properties: + type: + const: "car" + then: + properties: + numWheels: + type: integer + else: + properties: + numLegs: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Conditional") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // type="car" matches if condition (then branch), but we're using numLegs (else property) + data := map[string]any{ + "type": "car", + "numLegs": 4, // wrong branch property + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "numLegs", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_OneOfWithParentBothAdditionalPropertiesFalse(t *testing.T) { + // Test recurseIntoDeclaredPropertiesWithMerged path: + // Both parent and variant have additionalProperties: false + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + additionalProperties: false + properties: + id: + type: string + oneOf: + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Cat' + Dog: + type: object + additionalProperties: false + properties: + bark: + type: boolean + Cat: + type: object + additionalProperties: false + properties: + meow: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // All properties are declared (parent id + variant bark) + data := map[string]any{ + "id": "pet-123", + "bark": true, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Both parent and variant have additionalProperties: false + // This triggers the recurseIntoDeclaredPropertiesWithMerged path + // Standard validation would catch any extras, so strict just recurses + assert.True(t, result.Valid) +} + +func TestStrictValidator_OneOfWithParentBothAdditionalPropertiesFalse_NestedObject(t *testing.T) { + // Test recurseIntoDeclaredPropertiesWithMerged with nested object validation + // When both parent and variant have additionalProperties: false, the code + // takes the recurseIntoDeclaredPropertiesWithMerged path which still recurses + // into nested objects to check for undeclared properties there. + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + additionalProperties: false + properties: + id: + type: string + meta: + type: object + properties: + version: + type: string + oneOf: + - $ref: '#/components/schemas/Dog' + Dog: + type: object + additionalProperties: false + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Valid nested object - tests that recursion into nested objects works + data := map[string]any{ + "id": "pet-123", + "bark": true, + "meta": map[string]any{ + "version": "1.0", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // When both parent and variant have additionalProperties: false, + // strict mode delegates to standard validation for undeclared detection + // but still recurses into nested objects + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_MergePropertiesIntoDeclared_EmptySchema(t *testing.T) { + // Test mergePropertiesIntoDeclared with nil/empty schema + declared := make(map[string]*declaredProperty) + mergePropertiesIntoDeclared(declared, nil) + assert.Empty(t, declared) + + // Test with schema but nil properties + schema := &base.Schema{} + mergePropertiesIntoDeclared(declared, schema) + assert.Empty(t, declared) +} + +func TestStrictValidator_IsPropertyDeclaredInAllOf_EmptyAllOf(t *testing.T) { + // Test isPropertyDeclaredInAllOf with nil allOf + v := NewValidator(config.NewValidationOptions(config.WithStrictMode()), 3.1) + result := v.isPropertyDeclaredInAllOf(nil, "test") + assert.False(t, result) +} + +func TestStrictValidator_GetSchemaKey_NilSchema(t *testing.T) { + // Test getSchemaKey with nil schema returns empty string + v := NewValidator(config.NewValidationOptions(config.WithStrictMode()), 3.1) + key := v.getSchemaKey(nil) + assert.Equal(t, "", key) +} + +func TestStrictValidator_GetSchemaKey_SchemaWithHash(t *testing.T) { + // Test getSchemaKey with schema that has a hash + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + v := NewValidator(config.NewValidationOptions(config.WithStrictMode()), 3.1) + key := v.getSchemaKey(schema) + assert.NotEmpty(t, key) +} + +func TestStrictValidator_RecurseIntoDeclaredPropertiesWithMerged(t *testing.T) { + // Test the recurseIntoDeclaredPropertiesWithMerged code path + // This requires both parent AND variant to have additionalProperties: false + // AND the data to only contain properties declared in the variant + // (so the variant matching succeeds) + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + additionalProperties: false + properties: + name: + type: string + meta: + type: object + properties: + version: + type: string + oneOf: + - type: object + additionalProperties: false + properties: + name: + type: string + meta: + type: object + properties: + version: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data that only has properties declared in the variant + // The variant matches because it declares both name and meta + data := map[string]any{ + "name": "Fido", + "meta": map[string]any{ + "version": "1.0", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Both parent and variant have additionalProperties: false + // Strict mode delegates to base validation but still recurses into declared properties + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredPropertiesWithMerged_WithIgnorePath(t *testing.T) { + // Test the shouldIgnore path within recurseIntoDeclaredPropertiesWithMerged + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + additionalProperties: false + properties: + name: + type: string + details: + type: object + properties: + version: + type: string + oneOf: + - type: object + additionalProperties: false + properties: + name: + type: string + details: + type: object + properties: + version: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + // Ignore the details path + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.details"), + ) + v := NewValidator(opts, 3.1) + + // Data with properties that match both parent and variant + data := map[string]any{ + "name": "Fido", + "details": map[string]any{ + "version": "1.0", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should be valid - tests that ignore path works in recurseIntoDeclaredPropertiesWithMerged + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_ShouldSkipProperty_WriteOnly_Request(t *testing.T) { + // Test that writeOnly properties are not flagged in responses + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Password should be skipped in response direction + data := map[string]any{ + "id": "user-123", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // writeOnly in response should be flagged (password shouldn't be in response) + // Actually let me check the shouldSkipProperty logic + assert.True(t, result.Valid) +} + +func TestStrictValidator_IsPropertyDeclaredInAllOf_WithProperties(t *testing.T) { + // Test isPropertyDeclaredInAllOf with actual allOf schemas + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Combined: + allOf: + - type: object + properties: + name: + type: string + - type: object + properties: + age: + type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Combined") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test the isPropertyDeclaredInAllOf function + isDeclared := v.isPropertyDeclaredInAllOf(schema.AllOf, "name") + assert.True(t, isDeclared) + + isDeclared = v.isPropertyDeclaredInAllOf(schema.AllOf, "age") + assert.True(t, isDeclared) + + isDeclared = v.isPropertyDeclaredInAllOf(schema.AllOf, "undeclared") + assert.False(t, isDeclared) +} + +func TestDiscardHandler_Methods(t *testing.T) { + // Test the discardHandler slog.Handler implementation + // These are interface methods required by slog.Handler + + d := discardHandler{} + + // Enabled should return false (no logging) + assert.False(t, d.Enabled(context.TODO(), 0)) + + // Handle should return nil (no error) + assert.NoError(t, d.Handle(context.TODO(), slog.Record{})) + + // WithAttrs should return itself + handler := d.WithAttrs(nil) + assert.Equal(t, d, handler) + + // WithGroup should return itself + handler = d.WithGroup("test") + assert.Equal(t, d, handler) +} + +func TestStrictValidator_DataMatchesSchema_NilSchema(t *testing.T) { + // Test that nil schema matches anything + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + matches, err := v.dataMatchesSchema(nil, map[string]any{"foo": "bar"}) + assert.NoError(t, err) + assert.True(t, matches) +} + +func TestStrictValidator_GetCompiledSchema_NilSchema(t *testing.T) { + // Test getCompiledSchema with nil schema + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + compiled, err := v.getCompiledSchema(nil) + assert.NoError(t, err) + assert.Nil(t, compiled) +} + +func TestStrictValidator_GetCompiledSchema_LocalCacheHit(t *testing.T) { + // Test that local cache is used on second call + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // First call - compiles and caches + compiled1, err := v.getCompiledSchema(schema) + assert.NoError(t, err) + assert.NotNil(t, compiled1) + + // Second call - should hit local cache + compiled2, err := v.getCompiledSchema(schema) + assert.NoError(t, err) + assert.NotNil(t, compiled2) + assert.Same(t, compiled1, compiled2) +} + +func TestStrictValidator_CompileSchema_NilSchema(t *testing.T) { + // Test compileSchema with nil schema + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + compiled, err := v.compileSchema(nil) + assert.NoError(t, err) + assert.Nil(t, compiled) +} + +func TestStrictValidator_GetEffectiveIgnoredHeaders_WithMerge(t *testing.T) { + // Test getEffectiveIgnoredHeaders with merge mode + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnoredHeadersExtra("X-Custom"), + ) + v := NewValidator(opts, 3.1) + + headers := v.getEffectiveIgnoredHeaders() + // Should contain defaults plus the custom header + assert.Contains(t, headers, "content-type") // From defaults + assert.Contains(t, headers, "X-Custom") // From extra +} + +func TestStrictValidator_GetEffectiveIgnoredHeaders_WithReplace(t *testing.T) { + // Test getEffectiveIgnoredHeaders with replace mode + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnoredHeaders("X-Only-This"), + ) + v := NewValidator(opts, 3.1) + + headers := v.getEffectiveIgnoredHeaders() + // Should ONLY contain the replaced headers + assert.Contains(t, headers, "X-Only-This") + assert.NotContains(t, headers, "content-type") // Defaults should be replaced +} + +func TestStrictValidator_ValidateRequestHeaders_UndeclaredHeader(t *testing.T) { + // Test ValidateRequestHeaders with undeclared header + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: + /test: + get: + parameters: + - name: X-Known-Header + in: header + schema: + type: string + responses: + "200": + description: OK +` + doc, _ := libopenapi.NewDocument([]byte(yml)) + model, _ := doc.BuildV3Model() + + opts := config.NewValidationOptions(config.WithStrictMode()) + + params := model.Model.Paths.PathItems.GetOrZero("/test").Get.Parameters + + // Create headers directly + headers := http.Header{ + "X-Known-Header": {"value"}, + "X-Unknown-Header": {"value"}, // Not in spec + } + + // ValidateRequestHeaders takes http.Header, not *http.Request + undeclared := ValidateRequestHeaders(headers, params, opts) + + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Unknown-Header", undeclared[0].Name) +} + +func TestStrictValidator_ValidateValue_NilSchema(t *testing.T) { + // Test validateValue with nil schema + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateValue(ctx, nil, map[string]any{"foo": "bar"}) + assert.Empty(t, result) +} + +func TestStrictValidator_ValidateValue_NonObjectData(t *testing.T) { + // Test validateValue with non-object data (string, number, etc.) + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + StringType: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "StringType") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateValue(ctx, schema, "hello") + assert.Empty(t, result) +} + +func TestStrictValidator_FindMatchingVariant_NoMatch(t *testing.T) { + // Test findMatchingVariant when no variant matches + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + oneOf: + - type: object + required: + - bark + properties: + bark: + type: boolean + - type: object + required: + - meow + properties: + meow: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data that matches neither variant + data := map[string]any{ + "swim": true, + } + + variant := v.findMatchingVariant(schema.OneOf, data) + assert.Nil(t, variant) +} + +func TestStrictValidator_ShouldReportUndeclared_AdditionalPropertiesSchema(t *testing.T) { + // Test shouldReportUndeclared with additionalProperties as a schema + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Config: + type: object + properties: + name: + type: string + additionalProperties: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Config") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + result := v.shouldReportUndeclared(schema) + assert.True(t, result) // Should report undeclared even with additionalProperties schema +} + +func TestStrictValidator_GetEffectiveIgnoredHeaders_NilOptions(t *testing.T) { + // Test getEffectiveIgnoredHeaders with nil options + v := &Validator{options: nil} + headers := v.getEffectiveIgnoredHeaders() + assert.Nil(t, headers) +} + +func TestStrictValidator_ShouldSkipProperty_ReadOnlyInRequest(t *testing.T) { + // Test that readOnly properties are skipped in request direction + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Get the id property which is readOnly + idProp := schema.Properties.GetOrZero("id").Schema() + + // readOnly in request should be skipped + result := v.shouldSkipProperty(idProp, DirectionRequest) + assert.True(t, result) + + // readOnly in response should NOT be skipped + result = v.shouldSkipProperty(idProp, DirectionResponse) + assert.False(t, result) +} + +func TestStrictValidator_ShouldSkipProperty_WriteOnlyInResponse(t *testing.T) { + // Test that writeOnly properties are skipped in response direction + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + password: + type: string + writeOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Get the password property which is writeOnly + passwordProp := schema.Properties.GetOrZero("password").Schema() + + // writeOnly in response should be skipped + result := v.shouldSkipProperty(passwordProp, DirectionResponse) + assert.True(t, result) + + // writeOnly in request should NOT be skipped + result = v.shouldSkipProperty(passwordProp, DirectionRequest) + assert.False(t, result) +} + +func TestStrictValidator_ShouldReportUndeclared_UnevaluatedPropertiesFalse(t *testing.T) { + // Test that unevaluatedProperties: false still reports undeclared in strict mode + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Config: + type: object + properties: + name: + type: string + unevaluatedProperties: false +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Config") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + result := v.shouldReportUndeclared(schema) + assert.True(t, result) +} + +func TestStrictValidator_ValidateValue_ExceedsDepth(t *testing.T) { + // Test validateValue when max depth is exceeded + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + DeepNested: + type: object + properties: + level: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "DeepNested") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + // Increase depth artificially to exceed max + for i := 0; i < 101; i++ { + ctx = ctx.withPath("$.body.deep") + } + + result := v.validateValue(ctx, schema, map[string]any{"level": map[string]any{}}) + assert.Empty(t, result) // Should return early due to depth +} + +func TestStrictValidator_AnyOf_WithMatch(t *testing.T) { + // Test validateAnyOf with a matching variant + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + properties: + id: + type: string + anyOf: + - type: object + properties: + bark: + type: boolean + - type: object + properties: + meow: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data that matches the first variant + data := map[string]any{ + "id": "pet-123", + "bark": true, + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_AnyOf_WithDiscriminator(t *testing.T) { + // Test validateAnyOf with discriminator + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + discriminator: + propertyName: petType + mapping: + dog: '#/components/schemas/Dog' + anyOf: + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Cat' + Dog: + type: object + properties: + petType: + type: string + bark: + type: boolean + Cat: + type: object + properties: + petType: + type: string + meow: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "petType": "dog", + "bark": true, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) +} + +func TestStrictValidator_FindMatchingVariant_NilProxy(t *testing.T) { + // Test findMatchingVariant with nil proxy in variants + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create a slice with nil entry + variants := []*base.SchemaProxy{nil} + + result := v.findMatchingVariant(variants, map[string]any{"foo": "bar"}) + assert.Nil(t, result) +} + +func TestStrictValidator_ShouldReportUndeclaredForAllOf_AdditionalPropertiesFalse(t *testing.T) { + // Test shouldReportUndeclaredForAllOf when parent has additionalProperties: false + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Combined: + type: object + additionalProperties: false + allOf: + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Combined") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Should return false because parent has additionalProperties: false + result := v.shouldReportUndeclaredForAllOf(schema) + assert.False(t, result) +} + +func TestStrictValidator_ShouldReportUndeclaredForAllOf_AllOfHasAdditionalPropertiesFalse(t *testing.T) { + // Test shouldReportUndeclaredForAllOf when allOf member has additionalProperties: false + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Combined: + type: object + allOf: + - type: object + additionalProperties: false + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Combined") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Should return false because allOf member has additionalProperties: false + result := v.shouldReportUndeclaredForAllOf(schema) + assert.False(t, result) +} + +func TestStrictValidator_FindPropertySchemaInAllOf_FromDeclared(t *testing.T) { + // Test findPropertySchemaInAllOf finding property from declared map + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create declared map with the property + declared := make(map[string]*declaredProperty) + declared["name"] = &declaredProperty{ + proxy: schema.Properties.GetOrZero("name"), + } + + result := v.findPropertySchemaInAllOf(nil, "name", declared) + assert.NotNil(t, result) +} + +// Additional nil check tests + +func TestStrictValidator_IsPropertyDeclaredInAllOf_NilSchemaProxy(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with nil SchemaProxy in allOf slice + allOf := []*base.SchemaProxy{nil} + result := v.isPropertyDeclaredInAllOf(allOf, "foo") + assert.False(t, result) +} + +func TestStrictValidator_IsPropertyDeclaredInAllOf_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with empty allOf + result := v.isPropertyDeclaredInAllOf(nil, "foo") + assert.False(t, result) +} + +func TestStrictValidator_ShouldReportUndeclaredForAllOf_NilSchemaProxy(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Test: + type: object + allOf: + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Test") + + // Manually inject a nil into allOf to test the nil check + schema.AllOf = append(schema.AllOf, nil) + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Should still work and return true (default behavior) + result := v.shouldReportUndeclaredForAllOf(schema) + assert.True(t, result) +} + +func TestStrictValidator_FindPropertySchemaInAllOf_NilSchemaProxy(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with nil SchemaProxy in allOf + allOf := []*base.SchemaProxy{nil} + result := v.findPropertySchemaInAllOf(allOf, "foo", nil) + assert.Nil(t, result) +} + +func TestStrictValidator_RecurseIntoAllOfDeclaredProperties_NilSchemaProxy(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + allOf := []*base.SchemaProxy{nil} + data := map[string]any{"foo": "bar"} + + result := v.recurseIntoAllOfDeclaredProperties(ctx, allOf, data, nil) + assert.Empty(t, result) +} + +func TestStrictValidator_SelectByDiscriminator_NilDiscriminator(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Test: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Test") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Schema has no discriminator + result := v.selectByDiscriminator(schema, nil, map[string]any{"foo": "bar"}) + assert.Nil(t, result) +} + +func TestStrictValidator_SelectByDiscriminator_EmptyPropertyName(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + discriminator: + propertyName: "" + oneOf: + - $ref: '#/components/schemas/Dog' + Dog: + type: object + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + result := v.selectByDiscriminator(schema, schema.OneOf, map[string]any{"petType": "Dog"}) + assert.Nil(t, result) +} + +func TestStrictValidator_SelectByDiscriminator_MissingDiscriminatorValue(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + discriminator: + propertyName: petType + oneOf: + - $ref: '#/components/schemas/Dog' + Dog: + type: object + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data doesn't have the discriminator property + result := v.selectByDiscriminator(schema, schema.OneOf, map[string]any{"bark": true}) + assert.Nil(t, result) +} + +func TestStrictValidator_SelectByDiscriminator_NonStringValue(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + discriminator: + propertyName: petType + oneOf: + - $ref: '#/components/schemas/Dog' + Dog: + type: object + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Discriminator value is not a string + result := v.selectByDiscriminator(schema, schema.OneOf, map[string]any{"petType": 123}) + assert.Nil(t, result) +} + +func TestStrictValidator_SelectByDiscriminator_NoMatchingVariant(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Pet: + type: object + discriminator: + propertyName: petType + oneOf: + - $ref: '#/components/schemas/Dog' + Dog: + type: object + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Pet") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Discriminator value doesn't match any variant + result := v.selectByDiscriminator(schema, schema.OneOf, map[string]any{"petType": "Cat"}) + assert.Nil(t, result) +} + +func TestStrictValidator_FindMatchingVariant_NoMatch2(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Dog: + type: object + required: + - bark + properties: + bark: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Dog") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create variants with a schema that won't match the data + variants := []*base.SchemaProxy{base.CreateSchemaProxy(schema)} + + // Data doesn't have required 'bark' property - won't match + result := v.findMatchingVariant(variants, map[string]any{"meow": true}) + assert.Nil(t, result) +} + +func TestStrictValidator_CollectDeclaredProperties_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + declared, patterns := v.collectDeclaredProperties(nil, nil) + assert.Empty(t, declared) + assert.Empty(t, patterns) +} + +func TestStrictValidator_GetDeclaredPropertyNames_Empty(t *testing.T) { + result := getDeclaredPropertyNames(nil) + assert.Empty(t, result) +} + +func TestStrictValidator_ShouldSkipProperty_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + result := v.shouldSkipProperty(nil, DirectionRequest) + assert.False(t, result) +} + +func TestStrictValidator_ValidateObject_NilProperties(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Empty: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Empty") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateObject(ctx, schema, map[string]any{"foo": "bar"}) + + // In strict mode, empty schema with no properties still reports undeclared + // because additionalProperties defaults to true (meaning strict mode catches it) + assert.Len(t, result, 1) + assert.Equal(t, "foo", result[0].Name) +} + +func TestStrictValidator_ShouldReportUndeclared_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // nil schema returns false - can't report undeclared without schema + result := v.shouldReportUndeclared(nil) + assert.False(t, result) +} + +func TestStrictValidator_GetPatternPropertySchema_NoPatterns(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + NoPatterns: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "NoPatterns") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Schema has no patternProperties + result := v.getPatternPropertySchema(schema, "foo") + assert.Nil(t, result) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_EmptySchema(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Empty: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Empty") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + data := map[string]any{"name": "test"} + + // recurseIntoDeclaredProperties only takes ctx, schema, data + result := v.recurseIntoDeclaredProperties(ctx, schema, data) + assert.Empty(t, result) +} + +func TestStrictValidator_ValidateArray_NilItems(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + List: + type: array +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "List") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateArray(ctx, schema, []any{"foo", "bar"}) + + // Array with no items schema - anything is allowed + assert.Empty(t, result) +} + +func TestStrictValidator_ValidateArray_ItemsSchemaB(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + List: + type: array + items: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "List") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateArray(ctx, schema, []any{"foo", "bar"}) + + // items: true means all items are valid + assert.Empty(t, result) +} + +func TestStrictValidator_ValidateArray_PrefixItemsNil(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Tuple: + type: array + prefixItems: + - type: string + - type: integer +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Tuple") + + // Manually set one prefixItem to nil to test the nil check + schema.PrefixItems = append(schema.PrefixItems, nil) + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + result := v.validateArray(ctx, schema, []any{"foo", 42, "extra"}) + + // Should handle nil prefixItems gracefully + assert.Empty(t, result) +} + +func TestStrictValidator_FindPropertySchemaInMerged_NilProxy(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create declared map with nil proxy + declared := make(map[string]*declaredProperty) + declared["name"] = &declaredProperty{proxy: nil} + + // findPropertySchemaInMerged takes (variant, parent, propName, declared) + result := v.findPropertySchemaInMerged(nil, nil, "name", declared) + assert.Nil(t, result) +} + +func TestStrictValidator_RecurseIntoDeclaredPropertiesWithMerged_NilProxy(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + + // Create declared with nil proxy + declared := make(map[string]*declaredProperty) + declared["name"] = &declaredProperty{proxy: nil} + + data := map[string]any{"name": "test"} + + // recurseIntoDeclaredPropertiesWithMerged takes (ctx, variant, parent, data, declared) + result := v.recurseIntoDeclaredPropertiesWithMerged(ctx, nil, nil, data, declared) + assert.Empty(t, result) +} + +func TestStrictValidator_ValidateAnyOf_NoMatchingVariant(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + StringOrInt: + anyOf: + - type: string + minLength: 5 + - type: integer + minimum: 10 +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "StringOrInt") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + ctx := newTraversalContext(DirectionRequest, nil, "$.body") + + // Data is an object which won't match string or integer + result := v.validateAnyOf(ctx, schema, map[string]any{"foo": "bar"}) + + // Should return empty - no matching variant means we can't validate + assert.Empty(t, result) +} + +func TestStrictValidator_CompilePattern_EmptyPattern(t *testing.T) { + // Test compilePattern with empty pattern + result := compilePattern("") + assert.Nil(t, result) +} + +func TestStrictValidator_GetSchemaKey_NoLowLevel(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create a schema without low-level info + schema := &base.Schema{} + + key := v.getSchemaKey(schema) + // Should return pointer-based key + assert.NotEmpty(t, key) +} + +func TestTruncateValue_SmallMapUnchanged(t *testing.T) { + // Small map (<= 3 entries) should return unchanged + input := map[string]any{"a": 1, "b": 2} + result := TruncateValue(input) + assert.Equal(t, input, result) + + // Exactly 3 entries should also pass unchanged + input3 := map[string]any{"a": 1, "b": 2, "c": 3} + result3 := TruncateValue(input3) + assert.Equal(t, input3, result3) +} + +func TestTruncateValue_SmallArrayUnchanged(t *testing.T) { + // Small array (<= 3 entries) should return unchanged + input := []any{1, 2} + result := TruncateValue(input) + assert.Equal(t, input, result) + + // Exactly 3 entries should also pass unchanged + input3 := []any{1, 2, 3} + result3 := TruncateValue(input3) + assert.Equal(t, input3, result3) +} + +func TestStrictValidator_DataMatchesSchema_CompilationError(t *testing.T) { + // Create a schema with an invalid regex pattern that will fail compilation + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + BadPattern: + type: object + properties: + name: + type: string + pattern: "[invalid(regex" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "BadPattern") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // dataMatchesSchema should return false with error due to invalid pattern + matches, err := v.dataMatchesSchema(schema, map[string]any{"name": "test"}) + assert.False(t, matches) + assert.Error(t, err) + assert.Contains(t, err.Error(), "strict:") +} + +func TestStrictValidator_FindMatchingVariant_SchemaError(t *testing.T) { + // Create oneOf with a variant that has invalid pattern - should skip bad variant + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Container: + oneOf: + - type: object + properties: + valid: + type: string + - type: object + properties: + broken: + type: string + pattern: "[unclosed(" +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Container") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // findMatchingVariant should skip the broken variant and match the valid one + variant := v.findMatchingVariant(schema.OneOf, map[string]any{"valid": "test"}) + + // Should find a valid variant (the first one) + assert.NotNil(t, variant) +} + +func TestStrictValidator_GetPatternPropertySchema_InvalidPattern(t *testing.T) { + // Create schema with invalid patternProperties regex + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + BadPatternProps: + type: object + patternProperties: + "[invalid(": + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "BadPatternProps") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // getPatternPropertySchema should return nil for invalid pattern + propProxy := v.getPatternPropertySchema(schema, "test") + assert.Nil(t, propProxy) +} + +// ============================================================================= +// Phase 1: CRITICAL Coverage Tests +// ============================================================================= + +func TestStrictValidator_AllOfWithParentProperties(t *testing.T) { + // Covers polymorphic.go:88-91 - parent schema properties merged with allOf + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + MergedSchema: + type: object + properties: + parentProp: + type: string + allOf: + - type: object + properties: + childProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "MergedSchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Both parent and child properties should be considered declared + data := map[string]any{ + "parentProp": "from parent", + "childProp": "from child", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AllOfWithParentProperties_UndeclaredReported(t *testing.T) { + // Verify undeclared properties are still caught with parent+allOf merge + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + MergedSchema: + type: object + properties: + parentProp: + type: string + allOf: + - type: object + properties: + childProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "MergedSchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "parentProp": "from parent", + "childProp": "from child", + "undeclaredProp": "should be reported", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "undeclaredProp", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_AllOfReadOnlyInRequest(t *testing.T) { + // Covers polymorphic.go:116-117 - shouldSkipProperty for readOnly in allOf + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + ReadOnlyAllOf: + type: object + allOf: + - type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "ReadOnlyAllOf") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // In request direction, readOnly property should be skipped + data := map[string]any{ + "id": "123", + "name": "test", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // id is readOnly - should be skipped in request validation (not flagged) + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AllOfWriteOnlyInResponse(t *testing.T) { + // Covers polymorphic.go:222-223 - shouldSkipProperty for writeOnly in oneOf/anyOf + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + WriteOnlySchema: + type: object + oneOf: + - type: object + properties: + password: + type: string + writeOnly: true + email: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "WriteOnlySchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // In response direction, writeOnly property should be skipped + data := map[string]any{ + "password": "secret123", + "email": "user@example.com", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // password is writeOnly - should be skipped in response validation + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AllOfWithIgnoredPath(t *testing.T) { + // Covers polymorphic.go:107-108 - shouldIgnore in allOf validation loop + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + IgnoreInAllOf: + type: object + allOf: + - type: object + properties: + data: + type: object + properties: + visible: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "IgnoreInAllOf") + + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.data.metadata"), + ) + v := NewValidator(opts, 3.1) + + // metadata path is ignored, so undeclared properties there should not be reported + data := map[string]any{ + "data": map[string]any{ + "visible": "ok", + "metadata": map[string]any{ + "ignored": "should not be flagged", + "alsoIgnored": "also not flagged", + }, + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // metadata path is ignored, so no undeclared errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_OneOfWithIgnoredPath(t *testing.T) { + // Covers polymorphic.go:213-214 - shouldIgnore in oneOf/anyOf validation loop + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + IgnoreInOneOf: + type: object + oneOf: + - type: object + properties: + data: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "IgnoreInOneOf") + + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.data.internal"), + ) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "data": map[string]any{ + "name": "visible", + "internal": map[string]any{ + "secret": "ignored", + }, + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_OneOfWithIgnoredTopLevelProperty(t *testing.T) { + // Covers polymorphic.go:213-214 - shouldIgnore at TOP LEVEL of oneOf iteration + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfIgnoreTopLevel: + type: object + oneOf: + - type: object + properties: + name: + type: string + internal: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfIgnoreTopLevel") + + // Ignore "internal" property at top level - this directly hits line 214 + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.internal"), + ) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "visible", + "internal": map[string]any{ + "anything": "should be ignored entirely", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // internal property is ignored at top level, no errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_FindPropertySchemaInMerged_VariantProperty(t *testing.T) { + // Covers polymorphic.go:248-249 - property found in variant's explicit properties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfVariantProp: + type: object + properties: + parentProp: + type: string + oneOf: + - type: object + properties: + variantProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfVariantProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // variantProp is defined in variant, should be found via line 249 + data := map[string]any{ + "parentProp": "parent", + "variantProp": "variant", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_FindPropertySchemaInMerged_ParentProperty(t *testing.T) { + // Covers polymorphic.go:254-256 - property found in parent's explicit properties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfParentProp: + type: object + properties: + parentOnly: + type: string + oneOf: + - type: object + properties: + variantOnly: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfParentProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // parentOnly is NOT in variant, so findPropertySchemaInMerged falls through + // to parent lookup at line 254-256 + data := map[string]any{ + "parentOnly": "from parent", + "variantOnly": "from variant", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_FindPropertySchemaInMerged_VariantDirect(t *testing.T) { + // Covers polymorphic.go:247-249 - direct test with empty declared map + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Variant: + type: object + properties: + variantProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + variant := getSchema(t, model, "Variant") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Call with empty declared map - forces lookup in variant.Properties (line 247-249) + result := v.findPropertySchemaInMerged(variant, nil, "variantProp", make(map[string]*declaredProperty)) + assert.NotNil(t, result) +} + +func TestStrictValidator_FindPropertySchemaInMerged_ParentDirect(t *testing.T) { + // Covers polymorphic.go:254-256 - direct test with empty declared map, no variant + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Parent: + type: object + properties: + parentProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + parent := getSchema(t, model, "Parent") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Call with nil variant and empty declared - forces lookup in parent.Properties (line 254-256) + result := v.findPropertySchemaInMerged(nil, parent, "parentProp", make(map[string]*declaredProperty)) + assert.NotNil(t, result) +} + +func TestStrictValidator_FindPropertySchemaInAllOf_FromAllOfSchema(t *testing.T) { + // Covers polymorphic.go:437-439 - property found in allOf schema's explicit properties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfExplicitProp: + type: object + allOf: + - type: object + properties: + fromAllOf: + type: object + properties: + nested: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfExplicitProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // fromAllOf is in allOf schema, findPropertySchemaInAllOf should find it + // and recurse into nested object to detect undeclared + data := map[string]any{ + "fromAllOf": map[string]any{ + "nested": "valid", + "undeclared": "should be flagged", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // undeclared in nested object should be reported + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "undeclared", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_FindPropertySchemaInAllOf_Direct(t *testing.T) { + // Covers polymorphic.go:437-439 - direct test with empty declared map + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfSchema: + type: object + allOf: + - type: object + properties: + allOfProp: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfSchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Call with empty declared map - forces lookup in allOf schemas (line 437-439) + result := v.findPropertySchemaInAllOf(schema.AllOf, "allOfProp", make(map[string]*declaredProperty)) + assert.NotNil(t, result) +} + +func TestStrictValidator_RecurseIntoAllOfDeclaredProperties_ShouldIgnore(t *testing.T) { + // Covers polymorphic.go:455-456 - shouldIgnore in recurseIntoAllOfDeclaredProperties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfIgnore: + type: object + additionalProperties: false + allOf: + - type: object + additionalProperties: false + properties: + name: + type: string + metadata: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfIgnore") + + // Ignore metadata at top level + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.metadata"), + ) + v := NewValidator(opts, 3.1) + + // Both parent and allOf have additionalProperties: false, + // so we go through recurseIntoAllOfDeclaredProperties + // metadata is ignored at line 455-456 + data := map[string]any{ + "name": "test", + "metadata": map[string]any{ + "anything": "ignored", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoAllOfDeclaredProperties_SkipReadOnly(t *testing.T) { + // Covers polymorphic.go:461-462 - shouldSkipProperty in recurseIntoAllOfDeclaredProperties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfReadOnly: + type: object + additionalProperties: false + allOf: + - type: object + additionalProperties: false + properties: + name: + type: string + id: + type: string + readOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfReadOnly") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Both parent and allOf have additionalProperties: false, + // so we go through recurseIntoAllOfDeclaredProperties + // id is readOnly and skipped in request direction (line 461-462) + data := map[string]any{ + "name": "test", + "id": "should-be-skipped", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredPropertiesWithMerged_SkipReadOnly(t *testing.T) { + // Covers polymorphic.go:291-292 - shouldSkipProperty in recurseIntoDeclaredPropertiesWithMerged + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfWithReadOnly: + type: object + additionalProperties: false + properties: + name: + type: string + oneOf: + - type: object + additionalProperties: false + properties: + name: + type: string + id: + type: string + readOnly: true + data: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfWithReadOnly") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // In request direction, readOnly property "id" should be skipped (line 291-292) + // Both parent and variant have additionalProperties: false, so we go through + // recurseIntoDeclaredPropertiesWithMerged + // Note: variant must also declare "name" so data matches the variant + data := map[string]any{ + "name": "test", + "id": "should-be-skipped", + "data": "valid", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // id is readOnly and skipped in request, no validation errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AllOfAdditionalPropertiesFalseRecurse(t *testing.T) { + // Covers polymorphic.go:461-462, 467-468 - recursion with additionalProperties: false + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + RecurseTest: + type: object + additionalProperties: false + allOf: + - type: object + properties: + nested: + type: object + properties: + valid: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "RecurseTest") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // nested.extra should be reported as undeclared + data := map[string]any{ + "nested": map[string]any{ + "valid": "ok", + "extra": "should be flagged", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.nested.extra", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_OneOfVariantPropertyPriority(t *testing.T) { + // Covers polymorphic.go:248-250, 255-257 - findPropertySchemaInMerged + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + PriorityTest: + type: object + properties: + type: + type: string + oneOf: + - type: object + properties: + details: + type: object + properties: + variantField: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "PriorityTest") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // type is from parent, details is from variant + data := map[string]any{ + "type": "test", + "details": map[string]any{ + "variantField": "from variant", + "undeclared": "should be flagged", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // undeclared in details should be flagged + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "undeclared", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_PropertyDeclaredInAllOfChild(t *testing.T) { + // Covers polymorphic.go:46-47 - isPropertyDeclaredInAllOf continuation + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfChildProp: + type: object + properties: + parentOnly: + type: string + allOf: + - type: object + properties: + fromChild: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfChildProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // fromChild is declared in allOf child, should be considered declared + data := map[string]any{ + "parentOnly": "parent", + "fromChild": "child", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_ValidateAllOf_NilSchemaProxy(t *testing.T) { + // Covers polymorphic.go:67-68 - nil schemaProxy in allOf loop + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfWithNil: + type: object + allOf: + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfWithNil") + + // Inject nil into allOf array to test the nil check at line 67-68 + schema.AllOf = append(schema.AllOf, nil) + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "test", + "extra": "undeclared", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should still work - nil schemaProxy is skipped + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_ValidateAllOf_IgnoreTopLevelProperty(t *testing.T) { + // Covers polymorphic.go:107-108 - shouldIgnore for top-level property in allOf + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfIgnoreTopLevel: + type: object + allOf: + - type: object + properties: + name: + type: string + metadata: + type: object +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfIgnoreTopLevel") + + // Ignore the metadata property at top level + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.metadata"), + ) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "test", + "metadata": map[string]any{ + "anything": "should be ignored at this level", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // metadata property is ignored entirely, no errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +// ============================================================================= +// Phase 2: HIGH Priority Coverage Tests +// ============================================================================= + +func TestStrictValidator_SchemaCacheHit(t *testing.T) { + // Covers matcher.go:60-62 - global schema cache hit path + // Need a oneOf schema to trigger dataMatchesSchema which uses getCompiledSchema + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + DogVariant: + type: object + properties: + breed: + type: string + CatVariant: + type: object + properties: + color: + type: string + CachedSchema: + type: object + oneOf: + - $ref: '#/components/schemas/DogVariant' + - $ref: '#/components/schemas/CatVariant' +` + model := buildSchemaFromYAML(t, yml) + dogSchema := getSchema(t, model, "DogVariant") + + // Create options with schema cache + opts := config.NewValidationOptions(config.WithStrictMode()) + + // Pre-populate the GLOBAL cache with a compiled schema for the DogVariant hash + // This is what findMatchingVariant will check via dataMatchesSchema + hash := dogSchema.GoLow().Hash() + compiledSchema, err := helpers.NewCompiledSchemaWithVersion( + "test-cached", + []byte(`{"type":"object","properties":{"breed":{"type":"string"}}}`), + opts, + 3.1, + ) + require.NoError(t, err) + opts.SchemaCache.Store(hash, &libcache.SchemaCacheEntry{ + CompiledSchema: compiledSchema, + }) + + v := NewValidator(opts, 3.1) + + // Data that matches DogVariant + data := map[string]any{ + "breed": "labrador", + "extra": "undeclared", + } + + // Get the parent oneOf schema + parentSchema := getSchema(t, model, "CachedSchema") + + // Validation should hit the GLOBAL cache when checking oneOf variants + result := v.Validate(Input{ + Schema: parentSchema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Should still detect undeclared property + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_PrefixItemsWithIgnoredPath(t *testing.T) { + // Covers array_validator.go:48-50 - shouldIgnore in prefixItems loop + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + TupleIgnore: + type: array + prefixItems: + - type: object + properties: + id: + type: string + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "TupleIgnore") + + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body[0]"), + ) + v := NewValidator(opts, 3.1) + + // First item should be ignored entirely, second item should be validated + data := []any{ + map[string]any{ + "id": "1", + "extraInFirst": "ignored because path $.body[0] is ignored", + }, + map[string]any{ + "name": "test", + "extraInSecond": "should be flagged", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Only second item's extra property should be flagged + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extraInSecond", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body[1].extraInSecond", result.UndeclaredValues[0].Path) +} + +func TestStrictValidator_ItemsWithIgnoredPath(t *testing.T) { + // Covers array_validator.go:71-72 - shouldIgnore in items loop + // Need to ignore the ITEM PATH itself ($.body[0]) not a nested property + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + ArrayIgnore: + type: array + items: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "ArrayIgnore") + + // Ignore the first array item entirely ($.body[0]) + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body[0]"), + ) + v := NewValidator(opts, 3.1) + + data := []any{ + map[string]any{ + "name": "item1", + "extra": "should be ignored because $.body[0] is ignored", + }, + map[string]any{ + "name": "item2", + "extra": "should be flagged", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // First item ignored, only second item's extra should be flagged + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body[1].extra", result.UndeclaredValues[0].Path) +} + +func TestValidateRequestHeaders_DeclaredHeaderSkipped(t *testing.T) { + // Covers validator.go:123-125 - declared header skip in request validation + opts := config.NewValidationOptions(config.WithStrictMode()) + + // Create params with X-Custom header declared + params := []*v3.Parameter{ + { + Name: "X-Custom", + In: "header", + }, + { + Name: "X-Another", + In: "header", + }, + } + + headers := http.Header{ + "X-Custom": []string{"declared-value"}, + "X-Another": []string{"also-declared"}, + "X-Undeclared": []string{"should-be-flagged"}, + } + + undeclared := ValidateRequestHeaders(headers, params, opts) + + // Only X-Undeclared should be reported + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Undeclared", undeclared[0].Name) +} + +func TestValidateRequestHeaders_NilOrDisabled(t *testing.T) { + // Covers validator.go:103 - early return when headers nil, options nil, or strict mode disabled + params := []*v3.Parameter{{Name: "X-Custom", In: "header"}} + headers := http.Header{"X-Custom": []string{"value"}} + + // Test nil headers + result := ValidateRequestHeaders(nil, params, config.NewValidationOptions(config.WithStrictMode())) + assert.Nil(t, result) + + // Test nil options + result = ValidateRequestHeaders(headers, params, nil) + assert.Nil(t, result) + + // Test strict mode disabled + opts := config.NewValidationOptions() // strict mode off by default + result = ValidateRequestHeaders(headers, params, opts) + assert.Nil(t, result) +} + +func TestValidateRequestHeaders_IgnoredHeaderSkipped(t *testing.T) { + // Covers validator.go:129 - skip when header is in ignored list + opts := config.NewValidationOptions(config.WithStrictMode()) + + // No declared headers - but Content-Type is in default ignored list + params := []*v3.Parameter{} + + headers := http.Header{ + "Content-Type": []string{"application/json"}, // ignored by default + "Authorization": []string{"Bearer token"}, // ignored by default + "X-Custom": []string{"should-be-flagged"}, + } + + undeclared := ValidateRequestHeaders(headers, params, opts) + + // Only X-Custom should be reported (Content-Type and Authorization are ignored) + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Custom", undeclared[0].Name) +} + +func TestValidateResponseHeaders_DeclaredHeaderSkipped(t *testing.T) { + // Covers validator.go:219-223, 228-230 - declared header handling in response + opts := config.NewValidationOptions(config.WithStrictMode()) + + // Create declared headers map + declaredHeaders := make(map[string]*v3.Header) + declaredHeaders["X-Response-Id"] = &v3.Header{} + + headers := http.Header{ + "X-Response-Id": []string{"declared"}, + "X-Undeclared": []string{"should-be-flagged"}, + } + + undeclared := ValidateResponseHeaders(headers, &declaredHeaders, opts) + + // Only X-Undeclared should be reported + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Undeclared", undeclared[0].Name) +} + +func TestValidateResponseHeaders_WithDeclaredHeaders(t *testing.T) { + // Covers validator.go:219-223, 228-230 - building declared names list + opts := config.NewValidationOptions(config.WithStrictMode()) + + // Create declared headers map with multiple headers + declaredHeaders := make(map[string]*v3.Header) + declaredHeaders["X-Rate-Limit"] = &v3.Header{} + declaredHeaders["X-Request-Id"] = &v3.Header{} + + headers := http.Header{ + "X-Rate-Limit": []string{"100"}, + "X-Request-Id": []string{"abc123"}, + "X-Undeclared": []string{"flagged"}, + } + + undeclared := ValidateResponseHeaders(headers, &declaredHeaders, opts) + + // Only X-Undeclared should be reported + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Undeclared", undeclared[0].Name) +} + +func TestValidateResponseHeaders_IgnorePathMatch(t *testing.T) { + // Covers validator.go:239 - skip when header matches ignore path pattern + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.headers.x-internal"), + ) + + declaredHeaders := make(map[string]*v3.Header) + + headers := http.Header{ + "X-Internal": []string{"should-be-ignored"}, // matches ignore path + "X-Undeclared": []string{"should-be-flagged"}, + } + + undeclared := ValidateResponseHeaders(headers, &declaredHeaders, opts) + + // Only X-Undeclared should be reported (X-Internal matches ignore path) + assert.Len(t, undeclared, 1) + assert.Equal(t, "X-Undeclared", undeclared[0].Name) +} + +func TestNewValidator_WithIgnorePaths(t *testing.T) { + // Covers types.go:310-311 - compiledIgnorePaths populated + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.metadata", "$.body.internal"), + ) + + v := NewValidator(opts, 3.1) + + // Verify ignore paths are compiled + assert.NotNil(t, v) + assert.Len(t, v.compiledIgnorePaths, 2) + + // Test that the patterns work + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "TestSchema") + + // metadata and internal are undeclared properties that match ignore patterns + data := map[string]any{ + "name": "test", + "metadata": map[string]any{ + "ignored": "value", + }, + "internal": map[string]any{ + "deep": map[string]any{ + "nested": "also ignored", + }, + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // metadata and internal paths are ignored, so no undeclared errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestNewValidator_WithCustomLogger(t *testing.T) { + // Covers types.go:295 - custom logger from options + customLogger := slog.New(slog.NewTextHandler(nil, nil)) + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithLogger(customLogger), + ) + + v := NewValidator(opts, 3.1) + + // Verify the custom logger is used + assert.NotNil(t, v) + assert.Equal(t, customLogger, v.logger) +} + +// ============================================================================= +// Phase 3: MEDIUM Priority Tests +// ============================================================================= + +func TestStrictValidator_PrimitiveValuesIgnored(t *testing.T) { + // Covers schema_walker.go:37-38 - validateValue default case for primitives + // Primitive values (string, number, boolean) have no properties to check + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + StringSchema: + type: string + NumberSchema: + type: number + BooleanSchema: + type: boolean +` + model := buildSchemaFromYAML(t, yml) + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test string value - no properties to check + stringSchema := getSchema(t, model, "StringSchema") + result := v.Validate(Input{ + Schema: stringSchema, + Data: "just a string", + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) + + // Test number value + numberSchema := getSchema(t, model, "NumberSchema") + result = v.Validate(Input{ + Schema: numberSchema, + Data: 42.5, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) + + // Test boolean value + boolSchema := getSchema(t, model, "BooleanSchema") + result = v.Validate(Input{ + Schema: boolSchema, + Data: true, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_AdditionalPropertiesSchemaRecurse(t *testing.T) { + // Covers schema_walker.go:72-80 - recurse into additionalProperties schema + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AddlPropsNested: + type: object + properties: + id: + type: string + additionalProperties: + type: object + properties: + nested: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AddlPropsNested") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data with nested undeclared property inside additionalProperties + data := map[string]any{ + "id": "1", + "extra": map[string]any{ + "nested": "ok", + "bad": "undeclared inside extra", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Both "extra" at top level AND "bad" inside extra should be reported + assert.False(t, result.Valid) + assert.GreaterOrEqual(t, len(result.UndeclaredValues), 1) + + // Find undeclared values + foundExtra := false + foundBad := false + for _, uv := range result.UndeclaredValues { + if uv.Name == "extra" { + foundExtra = true + } + if uv.Name == "bad" { + foundBad = true + } + } + assert.True(t, foundExtra, "expected 'extra' to be reported as undeclared") + assert.True(t, foundBad, "expected 'bad' inside extra to be reported as undeclared") +} + +func TestStrictValidator_AdditionalPropertiesFalseShortCircuit(t *testing.T) { + // Covers schema_walker.go:113-115 - shouldReportUndeclared returns false + // When additionalProperties: false, JSON Schema handles it, not strict mode + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + NoExtras: + type: object + additionalProperties: false + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "NoExtras") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data with extra property - additionalProperties: false handles this + data := map[string]any{ + "id": "1", + "extra": "should be handled by JSON Schema, not strict", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Strict mode should NOT report this because additionalProperties: false + // means JSON Schema will handle it + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_PatternPropertiesWithAdditionalFalse(t *testing.T) { + // Covers schema_walker.go:223-228 - patternProperties with additionalProperties: false + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + PatternOnly: + type: object + additionalProperties: false + patternProperties: + "^x-": + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "PatternOnly") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // x-custom matches the pattern, so it's declared + data := map[string]any{ + "x-custom": "ok", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // x-custom matches pattern and additionalProperties: false handles the rest + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_InvalidPatternPropertiesRegex(t *testing.T) { + // Covers property_collector.go:46-49 - invalid regex skipped + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + InvalidPattern: + type: object + properties: + id: + type: string + patternProperties: + "[invalid(regex": + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "InvalidPattern") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Property name that would match the invalid pattern if it could compile + data := map[string]any{ + "id": "1", + "[invalid(regex": "value", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Invalid pattern is skipped, so the property is reported as undeclared + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "[invalid(regex", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_UnevaluatedItemsWithIgnoredPath(t *testing.T) { + // Covers array_validator.go:97-98 - shouldIgnore in unevaluatedItems + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + UnevalIgnore: + type: array + unevaluatedItems: + type: object + properties: + id: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "UnevalIgnore") + + // Ignore the first array element + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body[0]"), + ) + v := NewValidator(opts, 3.1) + + // First item has undeclared 'extra', but it should be ignored + data := []any{ + map[string]any{ + "id": "1", + "extra": "should be ignored at index 0", + }, + map[string]any{ + "id": "2", + "extra2": "should be reported at index 1", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // First item ignored, second item's extra2 should be reported + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra2", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_AdditionalPropertiesSchemaReportsUndeclared(t *testing.T) { + // Covers schema_walker.go:122-126 - additionalProperties with schema still reports + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + SchemaAddl: + type: object + additionalProperties: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "SchemaAddl") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Data with extra property allowed by additionalProperties schema + data := map[string]any{ + "extra": "ok per JSON Schema but flagged by strict", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Strict mode should still flag undeclared properties even when + // additionalProperties allows them + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_NilSchemaPassesValidation(t *testing.T) { + // Covers matcher.go:38-40 - nil schema handling in dataMatchesSchema + // When schema is nil, validation passes (no schema means anything matches) + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Test with nil schema directly using dataMatchesSchema + matches, err := v.dataMatchesSchema(nil, map[string]any{"key": "value"}) + assert.NoError(t, err) + assert.True(t, matches, "nil schema should match any data") + + // Also test with different data types + matches, err = v.dataMatchesSchema(nil, "string value") + assert.NoError(t, err) + assert.True(t, matches) + + matches, err = v.dataMatchesSchema(nil, 123) + assert.NoError(t, err) + assert.True(t, matches) + + matches, err = v.dataMatchesSchema(nil, []any{1, 2, 3}) + assert.NoError(t, err) + assert.True(t, matches) +} + +func TestStrictValidator_ValidateValue_ShouldIgnore(t *testing.T) { + // Covers schema_walker.go:17-18 - shouldIgnore in validateValue at ENTRY point + // Need to ignore $.body itself so the check happens at validateValue entry + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + IgnoreTest: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "IgnoreTest") + + // Ignore the entire body - validateValue entry should return early at line 18 + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body"), + ) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "valid", + "undeclared": "should be ignored because entire body is ignored", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Entire body is ignored, so no undeclared errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_ValidateValue_CycleDetection(t *testing.T) { + // Covers schema_walker.go:27-28 - cycle detection in validateValue + // Need to call validateValue directly with a pre-visited context + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + TestSchema: + type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "TestSchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Create a context and pre-mark the schema as visited at this path + ctx := newTraversalContext(DirectionRequest, v.compiledIgnorePaths, "$.body") + schemaKey := v.getSchemaKey(schema) + ctx.checkAndMarkVisited(schemaKey) // First visit - marks as visited + + data := map[string]any{ + "name": "test", + "undeclared": "should not be detected due to cycle", + } + + // Call validateValue directly - should hit line 28 (cycle detected) + result := v.validateValue(ctx, schema, data) + + // Cycle detected, returns early with no errors + assert.Empty(t, result) +} + +func TestStrictValidator_ShouldReportUndeclared_AdditionalPropertiesTrue(t *testing.T) { + // Covers schema_walker.go:119-120 - additionalProperties: true explicit + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + ExplicitTrue: + type: object + additionalProperties: true + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "ExplicitTrue") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Extra property allowed by additionalProperties: true but flagged by strict + data := map[string]any{ + "name": "test", + "extra": "should be flagged in strict mode", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // Strict mode should flag undeclared even with additionalProperties: true + assert.False(t, result.Valid) + assert.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "extra", result.UndeclaredValues[0].Name) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_PropertyNotInData(t *testing.T) { + // Covers schema_walker.go:179-180 - continue when schema property not in data + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + MissingProp: + type: object + additionalProperties: false + properties: + required: + type: string + optional: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "MissingProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Only provide 'required', not 'optional' - line 180 should be hit + data := map[string]any{ + "required": "value", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // No undeclared properties + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_SkipReadOnly(t *testing.T) { + // Covers schema_walker.go:194-195 - shouldSkipProperty in recurseIntoDeclaredProperties + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + ReadOnlyProp: + type: object + additionalProperties: false + properties: + name: + type: string + id: + type: string + readOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "ReadOnlyProp") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // Include readOnly property in request - should be skipped (line 195) + data := map[string]any{ + "name": "test", + "id": "should-be-skipped", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // id is readOnly and skipped, no errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_ShouldIgnore(t *testing.T) { + // Covers schema_walker.go:188-189 - shouldIgnore for explicit property + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + IgnoreProp: + type: object + additionalProperties: false + properties: + name: + type: string + metadata: + type: object + properties: + nested: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "IgnoreProp") + + // Ignore metadata property + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.metadata"), + ) + v := NewValidator(opts, 3.1) + + // metadata has undeclared property but is ignored (line 189) + data := map[string]any{ + "name": "test", + "metadata": map[string]any{ + "nested": "ok", + "undeclared": "should be ignored", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // metadata is ignored, no errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_PatternNoMatch(t *testing.T) { + // Covers schema_walker.go:210-211 - property doesn't match any patternProperty + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + PatternSchema: + type: object + additionalProperties: false + properties: + name: + type: string + patternProperties: + "^x-": + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "PatternSchema") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // "other" doesn't match explicit props or pattern "^x-" - line 211 hit + data := map[string]any{ + "name": "test", + "x-custom": "matches pattern", + "other": "doesn't match pattern", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // "other" doesn't match pattern, but additionalProperties: false handles it + // so strict mode doesn't report it + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_PatternSkipReadOnly(t *testing.T) { + // Covers schema_walker.go:225-226 - shouldSkipProperty for patternProperty + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + PatternReadOnly: + type: object + additionalProperties: false + properties: + name: + type: string + patternProperties: + "^x-": + type: string + readOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "PatternReadOnly") + + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + // x-custom matches pattern but schema is readOnly - skip in request (line 226) + data := map[string]any{ + "name": "test", + "x-custom": "matches readOnly pattern", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // x-custom matches pattern but is readOnly, skipped in request + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RecurseIntoDeclaredProperties_PatternShouldIgnore(t *testing.T) { + // Covers schema_walker.go:219-220 - shouldIgnore for patternProperty path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + PatternIgnore: + type: object + additionalProperties: false + properties: + name: + type: string + patternProperties: + "^x-": + type: object + properties: + nested: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "PatternIgnore") + + // Ignore the pattern-matched property path + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictIgnorePaths("$.body.x-custom"), + ) + v := NewValidator(opts, 3.1) + + // x-custom matches pattern, path matches ignore pattern - should skip (line 220) + data := map[string]any{ + "name": "test", + "x-custom": map[string]any{ + "nested": "valid", + "undeclared": "should be ignored because path is ignored", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + // x-custom path is ignored, so no undeclared errors + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} diff --git a/validator.go b/validator.go index 79ebdb3..29eec15 100644 --- a/validator.go +++ b/validator.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package validator @@ -6,6 +6,7 @@ package validator import ( "fmt" "net/http" + "sort" "sync" "github.com/pb33f/libopenapi" @@ -292,6 +293,10 @@ func (v *validator) ValidateHttpRequestWithPathItem(request *http.Request, pathI // wait for all the validations to complete <-doneChan + + // sort errors for deterministic ordering (async validation can return errors in any order) + sortValidationErrors(validationErrors) + return len(validationErrors) == 0, validationErrors } @@ -372,6 +377,17 @@ type ( validationFunctionAsync func(control chan struct{}, errorChan chan []*errors.ValidationError) ) +// sortValidationErrors sorts validation errors for deterministic ordering. +// Errors are sorted by validation type first, then by message. +func sortValidationErrors(errs []*errors.ValidationError) { + sort.Slice(errs, func(i, j int) bool { + if errs[i].ValidationType != errs[j].ValidationType { + return errs[i].ValidationType < errs[j].ValidationType + } + return errs[i].Message < errs[j].Message + }) +} + // warmSchemaCaches pre-compiles all schemas in the OpenAPI document and stores them in the validator caches. // This frontloads the compilation cost so that runtime validation doesn't need to compile schemas. func warmSchemaCaches( diff --git a/validator_examples_test.go b/validator_examples_test.go index 2a65626..a0fb8e2 100644 --- a/validator_examples_test.go +++ b/validator_examples_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package validator @@ -72,6 +72,8 @@ func ExampleNewValidator_validateHttpRequest() { } // 4. Create a new *http.Request (normally, this would be where the host application will pass in the request) + // Note: /pet/{petId} requires api_key OR petstore_auth (OAuth2). Since OAuth2 is not validated, + // the security check passes. The path parameter validation fails because "NotAValidPetId" is not an integer. request, _ := http.NewRequest(http.MethodGet, "/pet/NotAValidPetId", nil) // 5. Validate! @@ -83,8 +85,7 @@ func ExampleNewValidator_validateHttpRequest() { fmt.Printf("Type: %s, Failure: %s\n", e.ValidationType, e.Message) } } - // Output: Type: security, Failure: API Key api_key not found in header - // Type: parameter, Failure: Path parameter 'petId' is not a valid integer + // Output: Type: parameter, Failure: Path parameter 'petId' is not a valid integer } func ExampleNewValidator_validateHttpRequestSync() { @@ -120,8 +121,7 @@ func ExampleNewValidator_validateHttpRequestSync() { fmt.Printf("Type: %s, Failure: %s\n", e.ValidationType, e.Message) } } - // Type: parameter, Failure: Path parameter 'petId' is not a valid integer - // Output: Type: security, Failure: API Key api_key not found in header + // Output: Type: parameter, Failure: Path parameter 'petId' is not a valid integer } func ExampleNewValidator_validateHttpRequestResponse() { diff --git a/validator_test.go b/validator_test.go index 3244ed7..8e6e134 100644 --- a/validator_test.go +++ b/validator_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley // SPDX-License-Identifier: MIT package validator @@ -27,6 +27,7 @@ import ( "github.com/pb33f/libopenapi-validator/cache" "github.com/pb33f/libopenapi-validator/config" + "github.com/pb33f/libopenapi-validator/errors" "github.com/pb33f/libopenapi-validator/helpers" ) @@ -243,6 +244,145 @@ paths: assert.Len(t, errors, 0) } +func TestStrictMode_ValidateHttpRequestIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /things/{id}: + post: + parameters: + - in: path + name: id + required: true + schema: + type: string + - in: query + name: q + schema: + type: string + - in: header + name: X-Known + schema: + type: string + - in: cookie + name: session + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode()) + require.Empty(t, errs) + + body := map[string]any{ + "name": "ok", + "extra": "nope", + } + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/things/123?q=ok&extra=1", bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Known", "known") + request.Header.Set("X-Extra", "nope") + request.AddCookie(&http.Cookie{Name: "session", Value: "ok"}) + request.AddCookie(&http.Cookie{Name: "other", Value: "nope"}) + + valid, valErrs := v.ValidateHttpRequest(request) + assert.False(t, valid) + + strictSubTypes := make(map[string]bool) + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType { + strictSubTypes[vErr.ValidationSubType] = true + } + } + + assert.True(t, strictSubTypes[errors.StrictSubTypeProperty]) + assert.True(t, strictSubTypes[errors.StrictSubTypeHeader]) + assert.True(t, strictSubTypes[errors.StrictSubTypeQuery]) + assert.True(t, strictSubTypes[errors.StrictSubTypeCookie]) +} + +func TestStrictMode_ValidateHttpResponseHeadersIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /things/{id}: + get: + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + description: ok + headers: + X-Res: + schema: + type: string + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode()) + require.Empty(t, errs) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/things/123", http.NoBody) + + body := map[string]any{"ok": true} + bodyBytes, _ := json.Marshal(body) + + response := &http.Response{ + StatusCode: 200, + Header: http.Header{ + "Content-Type": {"application/json"}, + "X-Res": {"ok"}, + "X-Extra": {"nope"}, + }, + Body: io.NopCloser(bytes.NewBuffer(bodyBytes)), + } + + valid, valErrs := v.ValidateHttpResponse(request, response) + assert.False(t, valid) + + foundStrictHeader := false + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType && + vErr.ValidationSubType == errors.StrictSubTypeHeader { + foundStrictHeader = true + break + } + } + assert.True(t, foundStrictHeader) +} + func TestNewValidator_WithCustomFormat_FormatError(t *testing.T) { spec := `openapi: 3.1.0 paths: @@ -1353,9 +1493,10 @@ func TestNewValidator_PetStore_PetGet200_PathNotFound(t *testing.T) { valid, errors := v.ValidateHttpRequestResponse(request, res.Result()) assert.False(t, valid) - assert.Len(t, errors, 2) - assert.Equal(t, "API Key api_key not found in header", errors[0].Message) - assert.Equal(t, "Path parameter 'petId' is not a valid integer", errors[1].Message) + // Note: /pet/{petId} allows api_key OR petstore_auth (OAuth2). Since OAuth2 is not validated, + // security passes. Only the path parameter validation fails. + assert.Len(t, errors, 1) + assert.Equal(t, "Path parameter 'petId' is not a valid integer", errors[0].Message) } func TestNewValidator_PetStore_PetGet200(t *testing.T) { @@ -2254,3 +2395,46 @@ paths: }) assert.Greater(t, count, 0, "Schema cache should have entries from path-level parameters") } + +// TestSortValidationErrors tests that validation errors are sorted deterministically +func TestSortValidationErrors(t *testing.T) { + // Create errors in random order + errs := []*errors.ValidationError{ + {ValidationType: "security", Message: "API Key missing"}, + {ValidationType: "parameter", Message: "Path param invalid"}, + {ValidationType: "request", Message: "Body invalid"}, + {ValidationType: "parameter", Message: "Header missing"}, + {ValidationType: "security", Message: "Auth header missing"}, + } + + sortValidationErrors(errs) + + // Verify sorted by validation type first, then by message + assert.Equal(t, "parameter", errs[0].ValidationType) + assert.Equal(t, "Header missing", errs[0].Message) + assert.Equal(t, "parameter", errs[1].ValidationType) + assert.Equal(t, "Path param invalid", errs[1].Message) + assert.Equal(t, "request", errs[2].ValidationType) + assert.Equal(t, "Body invalid", errs[2].Message) + assert.Equal(t, "security", errs[3].ValidationType) + assert.Equal(t, "API Key missing", errs[3].Message) + assert.Equal(t, "security", errs[4].ValidationType) + assert.Equal(t, "Auth header missing", errs[4].Message) +} + +// TestSortValidationErrors_Empty tests sorting empty slice +func TestSortValidationErrors_Empty(t *testing.T) { + errs := []*errors.ValidationError{} + sortValidationErrors(errs) + assert.Empty(t, errs) +} + +// TestSortValidationErrors_SingleElement tests sorting single element slice +func TestSortValidationErrors_SingleElement(t *testing.T) { + errs := []*errors.ValidationError{ + {ValidationType: "parameter", Message: "Invalid value"}, + } + sortValidationErrors(errs) + assert.Len(t, errs, 1) + assert.Equal(t, "parameter", errs[0].ValidationType) +}