diff --git a/config/config.go b/config/config.go index 2198f43..f67ec0f 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,8 @@ type ValidationOptions struct { 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 + StrictRejectReadOnly bool // Reject readOnly properties in requests + StrictRejectWriteOnly bool // Reject writeOnly properties in responses } // Option Enables an 'Options pattern' approach @@ -88,6 +90,8 @@ func WithExistingOpts(options *ValidationOptions) Option { o.StrictIgnorePaths = options.StrictIgnorePaths o.StrictIgnoredHeaders = options.StrictIgnoredHeaders o.strictIgnoredHeadersMerge = options.strictIgnoredHeadersMerge + o.StrictRejectReadOnly = options.StrictRejectReadOnly + o.StrictRejectWriteOnly = options.StrictRejectWriteOnly } } } @@ -241,6 +245,24 @@ func WithStrictIgnorePaths(paths ...string) Option { } } +// WithStrictRejectReadOnly enables rejection of readOnly properties in requests. +// When enabled, readOnly properties present in request bodies are reported as +// validation errors instead of being silently skipped. +func WithStrictRejectReadOnly() Option { + return func(o *ValidationOptions) { + o.StrictRejectReadOnly = true + } +} + +// WithStrictRejectWriteOnly enables rejection of writeOnly properties in responses. +// When enabled, writeOnly properties present in response bodies are reported as +// validation errors instead of being silently skipped. +func WithStrictRejectWriteOnly() Option { + return func(o *ValidationOptions) { + o.StrictRejectWriteOnly = true + } +} + // 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. diff --git a/config/config_test.go b/config/config_test.go index a9ad844..d2f77dd 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -466,6 +466,8 @@ func TestWithLogger(t *testing.T) { func TestWithExistingOpts_StrictFields(t *testing.T) { original := &ValidationOptions{ StrictMode: true, + StrictRejectReadOnly: true, + StrictRejectWriteOnly: true, StrictIgnorePaths: []string{"$.body.*"}, StrictIgnoredHeaders: []string{"x-custom"}, strictIgnoredHeadersMerge: true, @@ -475,12 +477,24 @@ func TestWithExistingOpts_StrictFields(t *testing.T) { opts := NewValidationOptions(WithExistingOpts(original)) assert.True(t, opts.StrictMode) + assert.True(t, opts.StrictRejectReadOnly) + assert.True(t, opts.StrictRejectWriteOnly) 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 TestWithStrictRejectReadOnly(t *testing.T) { + opts := NewValidationOptions(WithStrictRejectReadOnly()) + assert.True(t, opts.StrictRejectReadOnly) +} + +func TestWithStrictRejectWriteOnly(t *testing.T) { + opts := NewValidationOptions(WithStrictRejectWriteOnly()) + assert.True(t, opts.StrictRejectWriteOnly) +} + func TestStrictModeWithIgnorePaths(t *testing.T) { paths := []string{"$.body.metadata.*"} opts := NewValidationOptions( diff --git a/errors/strict_errors.go b/errors/strict_errors.go index aac6e3c..5bbe8e0 100644 --- a/errors/strict_errors.go +++ b/errors/strict_errors.go @@ -11,12 +11,14 @@ import ( // StrictValidationType is the validation type for strict mode errors. const StrictValidationType = "strict" -// StrictValidationSubTypes for different kinds of undeclared values. +// StrictValidationSubTypes for different kinds of strict validation errors. const ( - StrictSubTypeProperty = "undeclared-property" - StrictSubTypeHeader = "undeclared-header" - StrictSubTypeQuery = "undeclared-query-param" - StrictSubTypeCookie = "undeclared-cookie" + StrictSubTypeProperty = "undeclared-property" + StrictSubTypeHeader = "undeclared-header" + StrictSubTypeQuery = "undeclared-query-param" + StrictSubTypeCookie = "undeclared-cookie" + StrictSubTypeReadOnlyProperty = "readonly-property" + StrictSubTypeWriteOnlyProperty = "writeonly-property" ) // UndeclaredPropertyError creates a ValidationError for an undeclared property. @@ -132,6 +134,62 @@ func UndeclaredCookieError( } } +// ReadOnlyPropertyError creates a ValidationError for a readOnly property in a request. +func ReadOnlyPropertyError( + path string, + name string, + value any, + requestPath string, + requestMethod string, + specLine int, + specCol int, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeReadOnlyProperty, + Message: fmt.Sprintf("request property '%s' at '%s' is readOnly and should not be sent in the request", + name, path), + Reason: fmt.Sprintf("Strict mode: property '%s' is marked readOnly in the schema", + name), + HowToFix: fmt.Sprintf("Remove the readOnly annotation from '%s' in the schema, "+ + "remove it from the request, or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + SpecLine: specLine, + SpecCol: specCol, + } +} + +// WriteOnlyPropertyError creates a ValidationError for a writeOnly property in a response. +func WriteOnlyPropertyError( + path string, + name string, + value any, + requestPath string, + requestMethod string, + specLine int, + specCol int, +) *ValidationError { + return &ValidationError{ + ValidationType: StrictValidationType, + ValidationSubType: StrictSubTypeWriteOnlyProperty, + Message: fmt.Sprintf("response property '%s' at '%s' is writeOnly and should not be returned in the response", + name, path), + Reason: fmt.Sprintf("Strict mode: property '%s' is marked writeOnly in the schema", + name), + HowToFix: fmt.Sprintf("Remove the writeOnly annotation from '%s' in the schema, "+ + "remove it from the response, or add '%s' to StrictIgnorePaths", name, path), + RequestPath: requestPath, + RequestMethod: requestMethod, + ParameterName: name, + Context: truncateForContext(value), + SpecLine: specLine, + SpecCol: specCol, + } +} + // truncateForContext creates a truncated string representation for error context. func truncateForContext(v any) string { switch val := v.(type) { diff --git a/errors/strict_errors_test.go b/errors/strict_errors_test.go index 160ef1f..127fd82 100644 --- a/errors/strict_errors_test.go +++ b/errors/strict_errors_test.go @@ -167,6 +167,58 @@ func TestUndeclaredCookieError(t *testing.T) { assert.Equal(t, "tracking", err.ParameterName) } +func TestReadOnlyPropertyError(t *testing.T) { + err := ReadOnlyPropertyError( + "$.body.id", + "id", + "user-123", + "/users", + "POST", + 10, + 5, + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeReadOnlyProperty, err.ValidationSubType) + assert.Contains(t, err.Message, "readOnly") + assert.Contains(t, err.Message, "'id'") + assert.Contains(t, err.Message, "'$.body.id'") + assert.Contains(t, err.Reason, "readOnly") + assert.Contains(t, err.HowToFix, "id") + assert.Equal(t, "/users", err.RequestPath) + assert.Equal(t, "POST", err.RequestMethod) + assert.Equal(t, "id", err.ParameterName) + assert.Equal(t, 10, err.SpecLine) + assert.Equal(t, 5, err.SpecCol) +} + +func TestWriteOnlyPropertyError(t *testing.T) { + err := WriteOnlyPropertyError( + "$.body.password", + "password", + "secret", + "/users/123", + "GET", + 20, + 3, + ) + + assert.NotNil(t, err) + assert.Equal(t, StrictValidationType, err.ValidationType) + assert.Equal(t, StrictSubTypeWriteOnlyProperty, err.ValidationSubType) + assert.Contains(t, err.Message, "writeOnly") + assert.Contains(t, err.Message, "'password'") + assert.Contains(t, err.Message, "'$.body.password'") + assert.Contains(t, err.Reason, "writeOnly") + assert.Contains(t, err.HowToFix, "password") + assert.Equal(t, "/users/123", err.RequestPath) + assert.Equal(t, "GET", err.RequestMethod) + assert.Equal(t, "password", err.ParameterName) + assert.Equal(t, 20, err.SpecLine) + assert.Equal(t, 3, err.SpecCol) +} + func TestTruncateForContext_String(t *testing.T) { // Short string should not be truncated short := truncateForContext("short") diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go index ff90959..ed41101 100644 --- a/requests/validate_body_test.go +++ b/requests/validate_body_test.go @@ -1614,6 +1614,50 @@ paths: assert.Len(t, errors, 0) } +func TestValidateBody_StrictMode_ReadOnlyProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewRequestBodyValidator(&m.Model, + config.WithStrictMode(), + config.WithStrictRejectReadOnly(), + ) + + body := map[string]interface{}{ + "id": "user-123", + "name": "John", + } + + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/users", + 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, "readOnly") + assert.Contains(t, errors[0].Message, "id") +} + func TestValidateRequestBody_XMLMarshalError(t *testing.T) { spec := []byte(` openapi: 3.1.0 diff --git a/requests/validate_request.go b/requests/validate_request.go index caaf5aa..251b8d1 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -332,18 +332,28 @@ func ValidateRequestSchema(input *ValidateRequestSchemaInput) (bool, []*errors.V 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, - undeclared.SpecLine, - undeclared.SpecCol, - )) + switch undeclared.Type { + case strict.TypeReadOnlyProperty: + validationErrors = append(validationErrors, + errors.ReadOnlyPropertyError( + undeclared.Path, undeclared.Name, undeclared.Value, + request.URL.Path, request.Method, + undeclared.SpecLine, undeclared.SpecCol, + )) + default: + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + undeclared.SpecLine, + undeclared.SpecCol, + )) + } } } } diff --git a/responses/validate_body_test.go b/responses/validate_body_test.go index aa56496..1a0a51a 100644 --- a/responses/validate_body_test.go +++ b/responses/validate_body_test.go @@ -1619,6 +1619,50 @@ paths: assert.Len(t, errs, 0) } +func TestValidateBody_StrictMode_WriteOnlyProperty(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users/123: + get: + responses: + '200': + content: + application/json: + schema: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewResponseBodyValidator(&m.Model, + config.WithStrictMode(), + config.WithStrictRejectWriteOnly(), + ) + + request, _ := http.NewRequest(http.MethodGet, "https://things.com/users/123", nil) + + responseBody := `{"name": "John", "password": "secret"}` + 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, "writeOnly") + assert.Contains(t, errs[0].Message, "password") +} + func TestValidateBody_ValidURLEncodedBody(t *testing.T) { spec := `openapi: 3.1.0 paths: diff --git a/responses/validate_response.go b/responses/validate_response.go index e0d4604..f750dc9 100644 --- a/responses/validate_response.go +++ b/responses/validate_response.go @@ -354,18 +354,28 @@ func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*errors 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, - undeclared.SpecLine, - undeclared.SpecCol, - )) + switch undeclared.Type { + case strict.TypeWriteOnlyProperty: + validationErrors = append(validationErrors, + errors.WriteOnlyPropertyError( + undeclared.Path, undeclared.Name, undeclared.Value, + request.URL.Path, request.Method, + undeclared.SpecLine, undeclared.SpecCol, + )) + default: + validationErrors = append(validationErrors, + errors.UndeclaredPropertyError( + undeclared.Path, + undeclared.Name, + undeclared.Value, + undeclared.DeclaredProperties, + undeclared.Direction.String(), + request.URL.Path, + request.Method, + undeclared.SpecLine, + undeclared.SpecCol, + )) + } } } } diff --git a/strict/polymorphic.go b/strict/polymorphic.go index d229f33..a425b31 100644 --- a/strict/polymorphic.go +++ b/strict/polymorphic.go @@ -113,6 +113,10 @@ func (v *Validator) validateAllOf(ctx *traversalContext, schema *base.Schema, da // Recurse into the property propSchema := v.findPropertySchemaInAllOf(schema.AllOf, propName, allDeclared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -219,6 +223,10 @@ func (v *Validator) validateVariantWithParent(ctx *traversalContext, parent *bas // Find the property schema (prefer variant, fallback to parent) propSchema := v.findPropertySchemaInMerged(variant, parent, propName, allDeclared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -293,6 +301,10 @@ func (v *Validator) recurseIntoDeclaredPropertiesWithMerged(ctx *traversalContex propSchema := v.findPropertySchemaInMerged(variant, parent, propName, declared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -463,6 +475,10 @@ func (v *Validator) recurseIntoAllOfDeclaredProperties(ctx *traversalContext, al propSchema := v.findPropertySchemaInAllOf(allOf, propName, declared) if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } diff --git a/strict/property_collector.go b/strict/property_collector.go index 24317b8..b2ff86a 100644 --- a/strict/property_collector.go +++ b/strict/property_collector.go @@ -147,6 +147,26 @@ func getPropertySchema(name string, declared map[string]*declaredProperty) *base return nil } +// checkReadWriteOnlyViolation checks if a property violates readOnly/writeOnly rules +// when the corresponding rejection flag is enabled. Returns a violation and true if so. +func (v *Validator) checkReadWriteOnlyViolation( + path string, name string, value any, + schema *base.Schema, direction Direction, +) (UndeclaredValue, bool) { + if schema == nil || v.options == nil { + return UndeclaredValue{}, false + } + if direction == DirectionRequest && v.options.StrictRejectReadOnly && + schema.ReadOnly != nil && *schema.ReadOnly { + return newReadWriteOnlyViolation(path, name, value, direction, schema), true + } + if direction == DirectionResponse && v.options.StrictRejectWriteOnly && + schema.WriteOnly != nil && *schema.WriteOnly { + return newReadWriteOnlyViolation(path, name, value, direction, schema), true + } + return UndeclaredValue{}, false +} + // 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 { diff --git a/strict/schema_walker.go b/strict/schema_walker.go index 042a91c..6bfa084 100644 --- a/strict/schema_walker.go +++ b/strict/schema_walker.go @@ -90,7 +90,10 @@ func (v *Validator) validateObject(ctx *traversalContext, schema *base.Schema, d if propProxy != nil { propSchema := propProxy.Schema() if propSchema != nil { - // check readOnly/writeOnly + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -191,6 +194,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema propSchema := propProxy.Schema() if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } @@ -222,6 +229,10 @@ func (v *Validator) recurseIntoDeclaredProperties(ctx *traversalContext, schema propSchema := propProxy.Schema() if propSchema != nil { + if violation, ok := v.checkReadWriteOnlyViolation(propPath, propName, propValue, propSchema, ctx.direction); ok { + undeclared = append(undeclared, violation) + continue + } if v.shouldSkipProperty(propSchema, ctx.direction) { continue } diff --git a/strict/types.go b/strict/types.go index 1784256..045fbee 100644 --- a/strict/types.go +++ b/strict/types.go @@ -62,6 +62,13 @@ func (d Direction) String() string { return "request" } +// Type constants for UndeclaredValue.Type, defined here for use in the +// request/response dispatch switch (existing types use inline strings). +const ( + TypeReadOnlyProperty = "readonly" + TypeWriteOnlyProperty = "writeonly" +) + // 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 { @@ -77,7 +84,7 @@ type UndeclaredValue struct { Value any // Type indicates what kind of value this is. - // one of: "property", "header", "query", "cookie", "item" + // one of: "property", "header", "query", "cookie", "item", "readonly", "writeonly" Type string // DeclaredProperties lists property names that ARE declared at this @@ -127,6 +134,24 @@ func newUndeclaredProperty(path, name string, value any, declaredNames []string, } } +// newReadWriteOnlyViolation creates an UndeclaredValue for a readOnly/writeOnly violation. +func newReadWriteOnlyViolation(path, name string, value any, direction Direction, schema *base.Schema) UndeclaredValue { + line, col := extractSchemaLocation(schema) + violationType := TypeReadOnlyProperty + if direction == DirectionResponse { + violationType = TypeWriteOnlyProperty + } + return UndeclaredValue{ + Path: path, + Name: name, + Value: TruncateValue(value), + Type: violationType, + Direction: direction, + SpecLine: line, + SpecCol: col, + } +} + // newUndeclaredParam creates an UndeclaredValue for an undeclared parameter (query/header/cookie). // note: parameters don't have SpecLine/SpecCol because they're defined in OpenAPI parameter objects, // not schema objects. the parameter itself is the issue, not a schema definition. diff --git a/strict/validator_test.go b/strict/validator_test.go index c4abfab..6e88b43 100644 --- a/strict/validator_test.go +++ b/strict/validator_test.go @@ -6183,3 +6183,697 @@ components: // Location should come from parent (not crash due to nil variant.GoLow()) assert.Greater(t, result[0].SpecLine, 0) } + +// ============== readOnly/writeOnly rejection tests ============== + +func TestStrictValidator_RejectReadOnly_InRequest(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(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + // readOnly property "id" sent in a request should be rejected + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.id", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) + assert.Equal(t, DirectionRequest, result.UndeclaredValues[0].Direction) +} + +func TestStrictValidator_RejectReadOnly_Disabled(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") + + // Flag off — backward compat: no violation + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + 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_RejectReadOnly_InResponse_NoEffect(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(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + // readOnly in response is fine — readOnly only applies to requests + data := map[string]any{ + "id": "user-123", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectReadOnly_NestedObject(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + metadata: + type: object + properties: + createdAt: + type: string + readOnly: true + updatedBy: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "metadata": map[string]any{ + "createdAt": "2024-01-01", + "updatedBy": "admin", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "createdAt", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.metadata.createdAt", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_AllOf(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Combined: + allOf: + - type: object + properties: + id: + type: string + readOnly: true + - type: object + properties: + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Combined") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "combined-1", + "name": "Test", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectWriteOnly_InResponse(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(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + // writeOnly property "password" in response should be rejected + data := map[string]any{ + "name": "John", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "password", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.password", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeWriteOnlyProperty, result.UndeclaredValues[0].Type) + assert.Equal(t, DirectionResponse, result.UndeclaredValues[0].Direction) +} + +func TestStrictValidator_RejectWriteOnly_Disabled(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") + + // Flag off — backward compat: no violation + opts := config.NewValidationOptions(config.WithStrictMode()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "password": "secret", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.True(t, result.Valid) + assert.Empty(t, result.UndeclaredValues) +} + +func TestStrictValidator_RejectWriteOnly_InRequest_NoEffect(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(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + // writeOnly in request is fine — writeOnly only applies to responses + data := map[string]any{ + "name": "John", + "password": "secret", + } + + 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_RejectWriteOnly_NestedObject(t *testing.T) { + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + auth: + type: object + properties: + token: + type: string + writeOnly: true + provider: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "John", + "auth": map[string]any{ + "token": "secret-token", + "provider": "google", + }, + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "token", result.UndeclaredValues[0].Name) + assert.Equal(t, "$.body.auth.token", result.UndeclaredValues[0].Path) + assert.Equal(t, TypeWriteOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectBoth_Enabled(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 + password: + type: string + writeOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "User") + + // Both flags on + opts := config.NewValidationOptions( + config.WithStrictMode(), + config.WithStrictRejectReadOnly(), + config.WithStrictRejectWriteOnly(), + ) + + // Test request: readOnly "id" should be rejected + v := NewValidator(opts, 3.1) + dataRequest := map[string]any{ + "id": "user-123", + "name": "John", + "password": "secret", + } + resultRequest := v.Validate(Input{ + Schema: schema, + Data: dataRequest, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.False(t, resultRequest.Valid) + require.Len(t, resultRequest.UndeclaredValues, 1) + assert.Equal(t, "id", resultRequest.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, resultRequest.UndeclaredValues[0].Type) + + // Test response: writeOnly "password" should be rejected + v2 := NewValidator(opts, 3.1) + dataResponse := map[string]any{ + "id": "user-123", + "name": "John", + "password": "secret", + } + resultResponse := v2.Validate(Input{ + Schema: schema, + Data: dataResponse, + Direction: DirectionResponse, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + assert.False(t, resultResponse.Valid) + require.Len(t, resultResponse.UndeclaredValues, 1) + assert.Equal(t, "password", resultResponse.UndeclaredValues[0].Name) + assert.Equal(t, TypeWriteOnlyProperty, resultResponse.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_AdditionalPropertiesFalse(t *testing.T) { + // Covers schema_walker.go recurseIntoDeclaredProperties: + // explicit property path and patternProperties path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + Strict: + type: object + additionalProperties: false + properties: + id: + type: string + readOnly: true + name: + type: string + patternProperties: + "^x-": + type: string + readOnly: true +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "Strict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-1", + "name": "John", + "x-custom": "value", + } + + 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) + + names := []string{result.UndeclaredValues[0].Name, result.UndeclaredValues[1].Name} + assert.Contains(t, names, "id") + assert.Contains(t, names, "x-custom") + for _, uv := range result.UndeclaredValues { + assert.Equal(t, TypeReadOnlyProperty, uv.Type) + } +} + +func TestStrictValidator_RejectReadOnly_OneOf(t *testing.T) { + // Covers polymorphic.go validateVariantWithParent path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfSchema: + type: object + oneOf: + - type: object + properties: + id: + type: string + readOnly: true + email: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "OneOfSchema") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "user-1", + "email": "test@example.com", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_OneOfAdditionalPropertiesFalse(t *testing.T) { + // Covers polymorphic.go recurseIntoDeclaredPropertiesWithMerged path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + OneOfStrict: + 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, "OneOfStrict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "name": "test", + "id": "should-be-rejected", + "data": "valid", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_RejectReadOnly_AllOfAdditionalPropertiesFalse(t *testing.T) { + // Covers polymorphic.go recurseIntoAllOfDeclaredProperties path + yml := `openapi: "3.1.0" +info: + title: Test + version: "1.0" +paths: {} +components: + schemas: + AllOfStrict: + type: object + additionalProperties: false + allOf: + - type: object + properties: + id: + type: string + readOnly: true + name: + type: string +` + model := buildSchemaFromYAML(t, yml) + schema := getSchema(t, model, "AllOfStrict") + + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + data := map[string]any{ + "id": "should-be-rejected", + "name": "John", + } + + result := v.Validate(Input{ + Schema: schema, + Data: data, + Direction: DirectionRequest, + Options: opts, + BasePath: "$.body", + Version: 3.1, + }) + + assert.False(t, result.Valid) + require.Len(t, result.UndeclaredValues, 1) + assert.Equal(t, "id", result.UndeclaredValues[0].Name) + assert.Equal(t, TypeReadOnlyProperty, result.UndeclaredValues[0].Type) +} + +func TestStrictValidator_CheckReadWriteOnlyViolation_NilSchema(t *testing.T) { + opts := config.NewValidationOptions(config.WithStrictMode(), config.WithStrictRejectReadOnly()) + v := NewValidator(opts, 3.1) + + _, ok := v.checkReadWriteOnlyViolation("$.body.x", "x", "val", nil, DirectionRequest) + assert.False(t, ok) +} diff --git a/validator_test.go b/validator_test.go index 0c44434..126bb63 100644 --- a/validator_test.go +++ b/validator_test.go @@ -2783,3 +2783,114 @@ components: assert.Empty(t, reqErrors, "no validation errors expected (uncached request)") }) } + +func TestStrictMode_RejectReadOnly_RequestIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users: + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + readOnly: true + name: + type: string + responses: + "201": + description: created` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode(), config.WithStrictRejectReadOnly()) + require.Empty(t, errs) + + body := map[string]any{ + "id": "user-123", + "name": "John", + } + bodyBytes, _ := json.Marshal(body) + + request, _ := http.NewRequest(http.MethodPost, "https://example.com/users", bytes.NewBuffer(bodyBytes)) + request.Header.Set("Content-Type", "application/json") + + valid, valErrs := v.ValidateHttpRequest(request) + assert.False(t, valid) + + foundReadOnly := false + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType && + vErr.ValidationSubType == errors.StrictSubTypeReadOnlyProperty { + foundReadOnly = true + break + } + } + assert.True(t, foundReadOnly, "should report readOnly violation") +} + +func TestStrictMode_RejectWriteOnly_ResponseIntegration(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /users/{id}: + get: + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + name: + type: string + password: + type: string + writeOnly: true` + + doc, err := libopenapi.NewDocument([]byte(spec)) + require.NoError(t, err) + + v, errs := NewValidator(doc, config.WithStrictMode(), config.WithStrictRejectWriteOnly()) + require.Empty(t, errs) + + request, _ := http.NewRequest(http.MethodGet, "https://example.com/users/123", http.NoBody) + + body := map[string]any{ + "name": "John", + "password": "secret", + } + bodyBytes, _ := json.Marshal(body) + + response := &http.Response{ + StatusCode: 200, + Header: http.Header{ + "Content-Type": {"application/json"}, + }, + Body: io.NopCloser(bytes.NewBuffer(bodyBytes)), + } + + valid, valErrs := v.ValidateHttpResponse(request, response) + assert.False(t, valid) + + foundWriteOnly := false + for _, vErr := range valErrs { + if vErr.ValidationType == errors.StrictValidationType && + vErr.ValidationSubType == errors.StrictSubTypeWriteOnlyProperty { + foundWriteOnly = true + break + } + } + assert.True(t, foundWriteOnly, "should report writeOnly violation") +}