Skip to main content

mockforge_bench/conformance/
schema_mutator.rs

1//! Schema-driven body mutator for the conformance self-test.
2//!
3//! Issue #79 round 17.2 — Srikanth wanted per-category positive *and*
4//! negative coverage that's actually informed by the spec, not just
5//! "send an empty body". Given a positive sample (`serde_json::Value`)
6//! and the resolved request-body schema, this produces a fixed,
7//! deterministic set of negative mutations that the server should
8//! reject with 4xx.
9//!
10//! Mutations are per-field (or per-top-level for compound shapes) so
11//! a 50-property body produces a handful of high-signal negatives
12//! instead of a combinatorial explosion. Each negative carries a
13//! label like `"request-body:type-mismatch:user.email"` so the
14//! self-test report tells you exactly which field caught (or didn't
15//! catch).
16//!
17//! Coverage:
18//! - **type mismatch**: replace a string field with a number, an
19//!   integer with a string, an object with an array.
20//! - **min/max bound break**: if `minimum`/`maximum` is declared,
21//!   step one past it. If `minLength`/`maxLength` is declared on a
22//!   string, produce a too-short or too-long value.
23//! - **pattern break**: if a `pattern` regex is declared, replace
24//!   with a string that definitely doesn't match (`"!!!"`).
25//! - **enum out-of-range**: if an `enum` constraint is declared,
26//!   replace with a value not in the enum.
27//! - **required field removed**: drop each required field one at a
28//!   time.
29//!
30//! Out of scope (for round 17.2):
31//! - `oneOf` / `anyOf` / `allOf` discriminator probes (round 17.4)
32//! - format-specific mutations (uuid / email / date) — these
33//!   typically also catch as pattern or type mismatch
34//! - deeply-nested mutations past depth 2 (would explode the matrix)
35
36use openapiv3::{
37    AdditionalProperties, NumberType, ObjectType, Schema, SchemaKind, StringType, Type,
38};
39use serde_json::{json, Value};
40
41/// One mutation: a labelled JSON value that the server's validator
42/// should reject. The label is informational only — the self-test
43/// reporter splits it on `:` to bucket into categories.
44#[derive(Debug, Clone)]
45pub struct BodyMutation {
46    /// Human-readable label, e.g. `request-body:type-mismatch:user.email`.
47    pub label: String,
48    /// The mutated JSON. Always serialisable.
49    pub body: Value,
50}
51
52/// Build the full set of schema-driven negatives for a positive
53/// `sample` against `schema`. Empty if neither sample nor schema gives
54/// enough information to mutate; callers fall back to the older
55/// schema-agnostic negatives (empty body, wrong-type top-level).
56pub fn mutate_body(sample: &Value, schema: &Schema) -> Vec<BodyMutation> {
57    let mut mutations = Vec::new();
58
59    // Top-level type mismatch: if the schema declares a top-level
60    // object, send an array; vice versa. This catches validators
61    // that bail on top-level shape before per-field rules.
62    if let SchemaKind::Type(t) = &schema.schema_kind {
63        match t {
64            Type::Object(_) => mutations.push(BodyMutation {
65                label: "request-body:type-mismatch:$root".to_string(),
66                body: json!([sample.clone()]),
67            }),
68            Type::Array(_) => mutations.push(BodyMutation {
69                label: "request-body:type-mismatch:$root".to_string(),
70                body: json!({"unexpected": sample.clone()}),
71            }),
72            _ => {}
73        }
74    }
75
76    // Per-field walk: only top-level + one nested layer to keep the
77    // matrix bounded.
78    if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
79        walk_object(sample, obj, "", &mut mutations);
80    }
81
82    mutations
83}
84
85fn walk_object(sample: &Value, obj: &ObjectType, prefix: &str, out: &mut Vec<BodyMutation>) {
86    let sample_obj = match sample.as_object() {
87        Some(o) => o,
88        None => return,
89    };
90
91    // Required-field removal: drop each required field one at a
92    // time.  Cap at the first 5 required fields to keep the matrix
93    // bounded for huge specs.
94    for field in obj.required.iter().take(5) {
95        if sample_obj.contains_key(field) {
96            let mut mutated = sample.clone();
97            if let Some(o) = mutated.as_object_mut() {
98                o.remove(field);
99            }
100            out.push(BodyMutation {
101                label: format!("request-body:required-removed:{}{}", prefix, field),
102                body: mutated,
103            });
104        }
105    }
106
107    // Per-property mutations. Iterate the *schema*'s declared
108    // properties (not the sample's keys) so a sample missing a field
109    // doesn't silently skip its mutation, and so unknown sample keys
110    // (which an OpenAPI spec wouldn't have schemas for) get skipped.
111    for (field_name, field_schema_ref) in obj.properties.iter().take(20) {
112        let field_schema = match field_schema_ref.as_item() {
113            Some(s) => s,
114            None => continue, // unresolved $ref — skip rather than guess
115        };
116        let path = format!("{}{}", prefix, field_name);
117        mutate_field(sample, sample_obj, field_name, field_schema, &path, out);
118    }
119
120    // Additional-properties probe: if the schema explicitly forbids
121    // additional properties, send one anyway.
122    if matches!(obj.additional_properties, Some(AdditionalProperties::Any(false))) {
123        let mut mutated = sample.clone();
124        if let Some(o) = mutated.as_object_mut() {
125            o.insert("self_test_extra_field".to_string(), json!("extra"));
126        }
127        out.push(BodyMutation {
128            label: format!("request-body:additional-property:{}$root", prefix),
129            body: mutated,
130        });
131    }
132}
133
134fn mutate_field(
135    sample: &Value,
136    _sample_obj: &serde_json::Map<String, Value>,
137    field_name: &str,
138    field_schema: &Schema,
139    path: &str,
140    out: &mut Vec<BodyMutation>,
141) {
142    // Helper to replace one field's value in the sample.
143    let set_field = |new: Value| -> Value {
144        let mut mutated = sample.clone();
145        if let Some(o) = mutated.as_object_mut() {
146            o.insert(field_name.to_string(), new);
147        }
148        mutated
149    };
150
151    match &field_schema.schema_kind {
152        SchemaKind::Type(Type::String(s)) => mutate_string_field(s, &set_field, path, out),
153        SchemaKind::Type(Type::Number(n)) => mutate_number_field(n, &set_field, path, out, false),
154        SchemaKind::Type(Type::Integer(_)) => {
155            // Integer + number share min/max + type-mismatch logic;
156            // model integers as number for the mutator, then add an
157            // integer-specific "make it a float" probe.
158            out.push(BodyMutation {
159                label: format!("request-body:type-mismatch:{}", path),
160                body: set_field(json!("not-an-integer")),
161            });
162            out.push(BodyMutation {
163                label: format!("request-body:integer-as-float:{}", path),
164                body: set_field(json!(1.5)),
165            });
166        }
167        SchemaKind::Type(Type::Boolean(_)) => {
168            out.push(BodyMutation {
169                label: format!("request-body:type-mismatch:{}", path),
170                body: set_field(json!("not-a-boolean")),
171            });
172        }
173        SchemaKind::Type(Type::Array(_)) => {
174            out.push(BodyMutation {
175                label: format!("request-body:type-mismatch:{}", path),
176                body: set_field(json!({"not-an-array": true})),
177            });
178        }
179        SchemaKind::Type(Type::Object(_)) => {
180            out.push(BodyMutation {
181                label: format!("request-body:type-mismatch:{}", path),
182                body: set_field(json!("not-an-object")),
183            });
184        }
185        // SchemaKind::OneOf/AnyOf/AllOf — out of scope for 17.2.
186        _ => {}
187    }
188
189    // Enum negatives apply regardless of the underlying type.
190    if !field_schema.schema_data.extensions.is_empty() {
191        // No-op — extensions don't drive enum logic.
192    }
193    if let SchemaKind::Type(Type::String(s)) = &field_schema.schema_kind {
194        if !s.enumeration.is_empty() {
195            // Send a value not in the enum.
196            out.push(BodyMutation {
197                label: format!("request-body:enum-out-of-range:{}", path),
198                body: set_field(json!("self-test-not-in-enum")),
199            });
200        }
201    }
202}
203
204fn mutate_string_field(
205    s: &StringType,
206    set_field: &dyn Fn(Value) -> Value,
207    path: &str,
208    out: &mut Vec<BodyMutation>,
209) {
210    // Type mismatch — a string should reject a number.
211    out.push(BodyMutation {
212        label: format!("request-body:type-mismatch:{}", path),
213        body: set_field(json!(12345)),
214    });
215
216    // minLength: send a 0-length string when minLength >= 1.
217    if let Some(min) = s.min_length {
218        if min >= 1 {
219            out.push(BodyMutation {
220                label: format!("request-body:min-length:{}", path),
221                body: set_field(json!("")),
222            });
223        }
224    }
225
226    // maxLength: send a string one past the cap.
227    if let Some(max) = s.max_length {
228        let too_long: String = "x".repeat(max.saturating_add(1).min(10_000));
229        out.push(BodyMutation {
230            label: format!("request-body:max-length:{}", path),
231            body: set_field(json!(too_long)),
232        });
233    }
234
235    // Pattern: send a value that definitely doesn't match a typical regex.
236    if let Some(_pattern) = &s.pattern {
237        // We don't try to invert the regex — just send a marker the
238        // user can grep. Most patterns require alphanumeric/email/uuid
239        // and "!!!" matches none of those.
240        out.push(BodyMutation {
241            label: format!("request-body:pattern:{}", path),
242            body: set_field(json!("!!!self-test-pattern-violation!!!")),
243        });
244    }
245}
246
247fn mutate_number_field(
248    n: &NumberType,
249    set_field: &dyn Fn(Value) -> Value,
250    path: &str,
251    out: &mut Vec<BodyMutation>,
252    _integer: bool,
253) {
254    // Type mismatch.
255    out.push(BodyMutation {
256        label: format!("request-body:type-mismatch:{}", path),
257        body: set_field(json!("not-a-number")),
258    });
259
260    // minimum: send minimum - 1 (or - 0.0001 if it's a float).
261    if let Some(min) = n.minimum {
262        out.push(BodyMutation {
263            label: format!("request-body:minimum:{}", path),
264            body: set_field(json!(min - 1.0)),
265        });
266    }
267    // maximum: send maximum + 1.
268    if let Some(max) = n.maximum {
269        out.push(BodyMutation {
270            label: format!("request-body:maximum:{}", path),
271            body: set_field(json!(max + 1.0)),
272        });
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use openapiv3::{ObjectType, ReferenceOr, Schema, SchemaData, SchemaKind, Type};
280    use std::collections::BTreeSet;
281
282    fn object_schema(props: Vec<(&str, Schema)>, required: Vec<&str>) -> Schema {
283        let mut obj = ObjectType::default();
284        for (name, schema) in props {
285            obj.properties.insert(name.to_string(), ReferenceOr::Item(Box::new(schema)));
286        }
287        obj.required = required.into_iter().map(|s| s.to_string()).collect();
288        Schema {
289            schema_data: SchemaData::default(),
290            schema_kind: SchemaKind::Type(Type::Object(obj)),
291        }
292    }
293
294    fn string_field(min: Option<usize>, max: Option<usize>, pattern: Option<&str>) -> Schema {
295        let s = StringType {
296            min_length: min,
297            max_length: max,
298            pattern: pattern.map(|p| p.to_string()),
299            ..Default::default()
300        };
301        Schema {
302            schema_data: SchemaData::default(),
303            schema_kind: SchemaKind::Type(Type::String(s)),
304        }
305    }
306
307    fn integer_field() -> Schema {
308        Schema {
309            schema_data: SchemaData::default(),
310            schema_kind: SchemaKind::Type(Type::Integer(openapiv3::IntegerType::default())),
311        }
312    }
313
314    #[test]
315    fn empty_for_non_object_root() {
316        let s = string_field(None, None, None);
317        let m = mutate_body(&json!("hello"), &s);
318        assert!(m.is_empty(), "string root produces no body mutations");
319    }
320
321    #[test]
322    fn required_field_removed_for_each_required() {
323        let s = object_schema(
324            vec![
325                ("name", string_field(None, None, None)),
326                ("age", integer_field()),
327            ],
328            vec!["name", "age"],
329        );
330        let m = mutate_body(&json!({"name": "Ada", "age": 30}), &s);
331        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
332        assert!(labels.contains("request-body:required-removed:name"), "{labels:?}");
333        assert!(labels.contains("request-body:required-removed:age"), "{labels:?}");
334    }
335
336    #[test]
337    fn type_mismatch_for_typed_fields() {
338        let s = object_schema(vec![("name", string_field(None, None, None))], vec![]);
339        let m = mutate_body(&json!({"name": "Ada"}), &s);
340        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
341        assert!(labels.contains("request-body:type-mismatch:name"), "{labels:?}");
342    }
343
344    #[test]
345    fn min_max_length_for_string() {
346        let s = object_schema(vec![("user", string_field(Some(3), Some(10), None))], vec![]);
347        let m = mutate_body(&json!({"user": "abc"}), &s);
348        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
349        assert!(labels.contains("request-body:min-length:user"), "{labels:?}");
350        assert!(labels.contains("request-body:max-length:user"), "{labels:?}");
351    }
352
353    #[test]
354    fn pattern_violation_emitted() {
355        let s = object_schema(
356            vec![("ssn", string_field(None, None, Some(r"^\d{3}-\d{2}-\d{4}$")))],
357            vec![],
358        );
359        let m = mutate_body(&json!({"ssn": "123-45-6789"}), &s);
360        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
361        assert!(labels.contains("request-body:pattern:ssn"), "{labels:?}");
362    }
363
364    #[test]
365    fn integer_specific_mutations() {
366        let s = object_schema(vec![("age", integer_field())], vec![]);
367        let m = mutate_body(&json!({"age": 30}), &s);
368        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
369        assert!(labels.contains("request-body:type-mismatch:age"), "{labels:?}");
370        assert!(labels.contains("request-body:integer-as-float:age"), "{labels:?}");
371    }
372
373    #[test]
374    fn root_type_mismatch_for_object_root() {
375        let s = object_schema(vec![], vec![]);
376        let m = mutate_body(&json!({}), &s);
377        let labels: BTreeSet<String> = m.iter().map(|x| x.label.clone()).collect();
378        assert!(labels.contains("request-body:type-mismatch:$root"), "{labels:?}");
379    }
380}