Skip to main content

mockforge_bench/conformance/
self_test.rs

1//! Positive + per-category negative request driver against a live server.
2//!
3//! Issue #79 round 13 (4) — Srikanth's (e) ask: a way to test both
4//! positive and negative compliance scenarios separately, where the
5//! positive cases should pass and the negative cases should be
6//! rejected.
7//!
8//! This module sits *alongside* the existing conformance executor
9//! (which drives k6 / native checks on a single positive call per
10//! operation). The self-test driver synthesises per-category
11//! deliberately-bad requests and asserts that the server actually
12//! rejects them with a 4xx — useful when verifying that
13//! `validate_request_with_all` is wired correctly for the user's spec
14//! (the exact gap that round-13 (3) fixed).
15//!
16//! Scope of the initial MVP: covers the highest-signal negatives —
17//! empty body when one is required, missing required query/header
18//! params, and wrong-type path params. Doesn't try to mutate every
19//! field of a JSON-Schema-validated body; that's a follow-up.
20
21use super::spec_driven::AnnotatedOperation;
22use reqwest::{Client, Method};
23use std::collections::BTreeMap;
24use std::time::Duration;
25
26/// Configuration for a self-test run.
27#[derive(Debug, Clone)]
28pub struct SelfTestConfig {
29    pub target_url: String,
30    pub skip_tls_verify: bool,
31    pub timeout: Duration,
32    /// Optional extra headers to attach to every request (e.g. auth).
33    pub extra_headers: Vec<(String, String)>,
34    /// Delay between requests to avoid hammering the server.
35    pub delay_between_requests: Duration,
36}
37
38impl Default for SelfTestConfig {
39    fn default() -> Self {
40        Self {
41            target_url: "http://localhost:3000".into(),
42            skip_tls_verify: false,
43            timeout: Duration::from_secs(15),
44            extra_headers: Vec::new(),
45            delay_between_requests: Duration::from_millis(0),
46        }
47    }
48}
49
50/// Outcome of a single test case (positive or negative).
51#[derive(Debug, Clone, serde::Serialize)]
52pub struct CaseOutcome {
53    pub label: String,
54    pub expected_4xx: bool,
55    pub actual_status: u16,
56    /// True when the response status matches expectation
57    /// (positive → 2xx-3xx, negative → 4xx).
58    pub passed: bool,
59}
60
61/// All cases run against one annotated operation.
62#[derive(Debug, Clone, serde::Serialize)]
63pub struct OperationResult {
64    pub method: String,
65    pub path: String,
66    pub positive: Option<CaseOutcome>,
67    pub negatives: Vec<CaseOutcome>,
68}
69
70/// Summary report rolled up across all operations.
71#[derive(Debug, Default, Clone, serde::Serialize)]
72pub struct SelfTestReport {
73    pub positive_pass: usize,
74    pub positive_fail: usize,
75    /// Per category: count of negative cases the server correctly
76    /// rejected with a 4xx (we caught the spec violation).
77    pub negative_caught: BTreeMap<String, usize>,
78    /// Per category: count of negative cases that should have been
79    /// rejected but came back with a non-4xx (validator gap).
80    pub negative_missed: BTreeMap<String, usize>,
81    pub operations: Vec<OperationResult>,
82}
83
84impl SelfTestReport {
85    /// All-pass means every positive case got 2xx-3xx and every
86    /// negative case got 4xx.
87    pub fn all_passed(&self) -> bool {
88        self.positive_fail == 0 && self.negative_missed.values().sum::<usize>() == 0
89    }
90
91    /// Human-readable summary string. One line for positives, one per
92    /// category for negatives. Designed to slot into existing
93    /// `TerminalReporter` output.
94    pub fn render_summary(&self) -> String {
95        let mut out = String::new();
96        out.push_str(&format!(
97            "Positives: {} pass / {} fail\n",
98            self.positive_pass, self.positive_fail
99        ));
100        let mut keys: Vec<&String> =
101            self.negative_caught.keys().chain(self.negative_missed.keys()).collect();
102        keys.sort();
103        keys.dedup();
104        for cat in keys {
105            let caught = self.negative_caught.get(cat).copied().unwrap_or(0);
106            let missed = self.negative_missed.get(cat).copied().unwrap_or(0);
107            let mark = if missed == 0 { "✓" } else { "⚠" };
108            out.push_str(&format!(
109                "Negatives [{}]: {} caught / {} missed  {}\n",
110                cat, caught, missed, mark
111            ));
112        }
113        out
114    }
115}
116
117/// Execute the self-test plan against `config.target_url` for every
118/// `AnnotatedOperation`. Returns the aggregated report; callers
119/// decide how to display it (e.g. via `render_summary` or by writing
120/// the JSON serialisation to disk).
121pub async fn run_self_test(
122    operations: &[AnnotatedOperation],
123    config: &SelfTestConfig,
124) -> Result<SelfTestReport, reqwest::Error> {
125    let mut builder = Client::builder().timeout(config.timeout);
126    if config.skip_tls_verify {
127        builder = builder.danger_accept_invalid_certs(true);
128    }
129    let client = builder.build()?;
130
131    let mut report = SelfTestReport::default();
132    for op in operations {
133        let result = test_operation(&client, config, op).await;
134        if let Some(p) = &result.positive {
135            if p.passed {
136                report.positive_pass += 1;
137            } else {
138                report.positive_fail += 1;
139            }
140        }
141        for neg in &result.negatives {
142            let cat = neg.label.split(':').next().unwrap_or("other").to_string();
143            if neg.passed {
144                *report.negative_caught.entry(cat).or_insert(0) += 1;
145            } else {
146                *report.negative_missed.entry(cat).or_insert(0) += 1;
147            }
148        }
149        report.operations.push(result);
150        if !config.delay_between_requests.is_zero() {
151            tokio::time::sleep(config.delay_between_requests).await;
152        }
153    }
154    Ok(report)
155}
156
157async fn test_operation(
158    client: &Client,
159    config: &SelfTestConfig,
160    op: &AnnotatedOperation,
161) -> OperationResult {
162    let url = build_url(&config.target_url, &op.path, &op.path_params);
163    let method = Method::from_bytes(op.method.to_uppercase().as_bytes()).unwrap_or(Method::GET);
164
165    // ── Positive case ────────────────────────────────────────────
166    let positive = send_case(
167        client,
168        config,
169        method.clone(),
170        &url,
171        "positive",
172        false,
173        op.sample_body.as_deref(),
174        op.query_params.clone(),
175        op.header_params.clone(),
176    )
177    .await;
178
179    // ── Negative cases ───────────────────────────────────────────
180    let mut negatives = Vec::new();
181
182    // (a) empty body when one is required.
183    //
184    // Round 16 — drop the `sample_body.is_some()` precondition. Operations
185    // whose body annotator couldn't synthesize a sample previously got
186    // zero negatives (so the self-test reported "all passing" even on
187    // POST /resource with a required body). The spec saying the operation
188    // *has* a request body is enough — an empty object is a valid
189    // negative regardless of whether we have a positive sample.
190    if op.request_body_content_type.is_some() {
191        negatives.push(
192            send_case(
193                client,
194                config,
195                method.clone(),
196                &url,
197                "request-body:empty",
198                true,
199                Some("{}"),
200                op.query_params.clone(),
201                op.header_params.clone(),
202            )
203            .await,
204        );
205
206        // (b) wrong-shaped body (array instead of object) — exercises
207        // top-level type validation independently of which fields are
208        // required.
209        negatives.push(
210            send_case(
211                client,
212                config,
213                method.clone(),
214                &url,
215                "request-body:wrong-type",
216                true,
217                Some("[]"),
218                op.query_params.clone(),
219                op.header_params.clone(),
220            )
221            .await,
222        );
223    }
224
225    // (e) Round 16 — path-param type probe. Send the first path
226    // parameter as a literal `"self-test-invalid-id"`: a string that
227    // contains hyphens, won't parse as an integer, won't parse as a
228    // UUID, and won't match any typical regex pattern. Operations
229    // whose spec types the param as `integer` or `string` with a
230    // `format`/`pattern` will catch this (caught: server returned
231    // 4xx); operations whose spec lets path params be free-form
232    // strings will let it through (missed: server returned 2xx).
233    // Either outcome is informative: a category that's all "missed"
234    // tells the user their spec is loose on path-param types, which
235    // is itself worth knowing. Addresses Srikanth's "always all
236    // passing" report — operations with a path param now produce at
237    // least one probe instead of zero.
238    if !op.path_params.is_empty() {
239        let mut url_with_placeholder = op.path.clone();
240        if let Some((first_name, _)) = op.path_params.first() {
241            // Substitute every other path-param with its sample so the
242            // route shape stays intact and only the first param is bad.
243            for (name, value) in op.path_params.iter().skip(1) {
244                if !value.is_empty() {
245                    url_with_placeholder =
246                        url_with_placeholder.replace(&format!("{{{name}}}"), value);
247                }
248            }
249            // Substitute the first param with a guaranteed-invalid
250            // sentinel that's unlikely to match any reasonable schema:
251            // contains characters disallowed in numeric IDs *and* UUIDs.
252            url_with_placeholder =
253                url_with_placeholder.replace(&format!("{{{first_name}}}"), "self-test-invalid-id");
254            let target = config.target_url.trim_end_matches('/');
255            let bad_url = if url_with_placeholder.starts_with('/') {
256                format!("{}{}", target, url_with_placeholder)
257            } else {
258                format!("{}/{}", target, url_with_placeholder)
259            };
260            negatives.push(
261                send_case(
262                    client,
263                    config,
264                    method.clone(),
265                    &bad_url,
266                    "parameters:bad-path-param",
267                    true,
268                    op.sample_body.as_deref(),
269                    op.query_params.clone(),
270                    op.header_params.clone(),
271                )
272                .await,
273            );
274        }
275    }
276
277    // (c) drop the first required query param
278    if !op.query_params.is_empty() {
279        let mut q = op.query_params.clone();
280        q.remove(0);
281        negatives.push(
282            send_case(
283                client,
284                config,
285                method.clone(),
286                &url,
287                "parameters:missing-query",
288                true,
289                op.sample_body.as_deref(),
290                q,
291                op.header_params.clone(),
292            )
293            .await,
294        );
295    }
296
297    // (d) drop the first required header
298    if !op.header_params.is_empty() {
299        let mut h = op.header_params.clone();
300        h.remove(0);
301        negatives.push(
302            send_case(
303                client,
304                config,
305                method.clone(),
306                &url,
307                "parameters:missing-header",
308                true,
309                op.sample_body.as_deref(),
310                op.query_params.clone(),
311                h,
312            )
313            .await,
314        );
315    }
316
317    OperationResult {
318        method: op.method.clone(),
319        path: op.path.clone(),
320        positive: Some(positive),
321        negatives,
322    }
323}
324
325#[allow(clippy::too_many_arguments)]
326async fn send_case(
327    client: &Client,
328    config: &SelfTestConfig,
329    method: Method,
330    url: &str,
331    label: &str,
332    expected_4xx: bool,
333    body: Option<&str>,
334    query: Vec<(String, String)>,
335    headers: Vec<(String, String)>,
336) -> CaseOutcome {
337    let mut req = client.request(method, url);
338    for (k, v) in &query {
339        req = req.query(&[(k.as_str(), v.as_str())]);
340    }
341    for (k, v) in &headers {
342        req = req.header(k, v);
343    }
344    for (k, v) in &config.extra_headers {
345        req = req.header(k, v);
346    }
347    if let Some(b) = body {
348        req = req
349            .header(reqwest::header::CONTENT_TYPE, "application/json")
350            .body(b.to_string());
351    }
352
353    let actual_status = match req.send().await {
354        Ok(resp) => resp.status().as_u16(),
355        Err(e) if e.is_timeout() => 0,
356        Err(_) => 0,
357    };
358
359    let passed = if expected_4xx {
360        (400..500).contains(&actual_status)
361    } else {
362        (200..400).contains(&actual_status)
363    };
364
365    CaseOutcome {
366        label: label.to_string(),
367        expected_4xx,
368        actual_status,
369        passed,
370    }
371}
372
373/// Substitute `{param}` placeholders in the spec path with their
374/// sample values from `path_params`, then prepend `target_url`. Empty
375/// values are kept as `{param}` so an upstream router still matches
376/// the template — useful when `path_params` is empty and we want to
377/// hit the same route the spec defines.
378fn build_url(target: &str, path_template: &str, path_params: &[(String, String)]) -> String {
379    let mut url = path_template.to_string();
380    for (name, value) in path_params {
381        let placeholder = format!("{{{}}}", name);
382        if !value.is_empty() {
383            url = url.replace(&placeholder, value);
384        }
385    }
386    let target = target.trim_end_matches('/');
387    if url.starts_with('/') {
388        format!("{}{}", target, url)
389    } else {
390        format!("{}/{}", target, url)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn op(
399        method: &str,
400        path: &str,
401        body: Option<&str>,
402        query: Vec<(&str, &str)>,
403        headers: Vec<(&str, &str)>,
404        path_params: Vec<(&str, &str)>,
405    ) -> AnnotatedOperation {
406        AnnotatedOperation {
407            method: method.into(),
408            path: path.into(),
409            features: Vec::new(),
410            request_body_content_type: body.map(|_| "application/json".into()),
411            sample_body: body.map(|s| s.to_string()),
412            query_params: query.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
413            header_params: headers.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
414            path_params: path_params.into_iter().map(|(a, b)| (a.into(), b.into())).collect(),
415            response_schema: None,
416            security_schemes: Vec::new(),
417        }
418    }
419
420    #[test]
421    fn build_url_substitutes_path_params() {
422        let url = build_url(
423            "https://api.test/",
424            "/users/{id}/posts/{pid}",
425            &[("id".into(), "42".into()), ("pid".into(), "7".into())],
426        );
427        assert_eq!(url, "https://api.test/users/42/posts/7");
428    }
429
430    #[test]
431    fn build_url_keeps_placeholders_when_no_sample() {
432        let url = build_url("https://api.test", "/users/{id}", &[]);
433        assert_eq!(url, "https://api.test/users/{id}");
434    }
435
436    #[test]
437    fn report_summary_calls_out_misses() {
438        let r = SelfTestReport {
439            positive_pass: 3,
440            positive_fail: 0,
441            negative_caught: BTreeMap::from([("request-body".into(), 2)]),
442            negative_missed: BTreeMap::from([("request-body".into(), 1)]),
443            operations: Vec::new(),
444        };
445        let summary = r.render_summary();
446        assert!(summary.contains("Positives: 3 pass / 0 fail"));
447        assert!(summary.contains("Negatives [request-body]: 2 caught / 1 missed"));
448        assert!(summary.contains("⚠"));
449        assert!(!r.all_passed());
450    }
451
452    #[test]
453    fn report_all_passed_when_no_miss() {
454        let r = SelfTestReport {
455            positive_pass: 5,
456            positive_fail: 0,
457            negative_caught: BTreeMap::from([("parameters".into(), 3)]),
458            negative_missed: BTreeMap::new(),
459            operations: Vec::new(),
460        };
461        assert!(r.all_passed());
462        assert!(r.render_summary().contains("✓"));
463    }
464
465    #[tokio::test]
466    async fn run_self_test_against_unreachable_target_marks_all_failed() {
467        // Use an obviously-dead port so we exercise the timeout/error
468        // path without needing a live server in tests.
469        let cfg = SelfTestConfig {
470            target_url: "http://127.0.0.1:1".into(),
471            timeout: Duration::from_millis(200),
472            ..Default::default()
473        };
474        let ops = vec![op(
475            "POST",
476            "/users",
477            Some("{\"name\":\"a\"}"),
478            vec![],
479            vec![],
480            vec![],
481        )];
482        let report = run_self_test(&ops, &cfg).await.expect("client builds");
483        // All cases hit the connect-error path → actual_status=0.
484        // Positive expects 2xx-3xx → 0 is fail. Negatives expect 4xx
485        // → 0 is also fail (we missed catching).
486        assert_eq!(report.positive_fail, 1);
487        assert!(report.negative_missed.values().sum::<usize>() >= 1);
488        assert!(!report.all_passed());
489    }
490
491    /// Round 16 — operations with a body OR a path-param now produce
492    /// negatives even without a sample body. Previously a POST whose
493    /// body annotator failed produced *zero* negatives, so the self-test
494    /// always reported "all passing" for that endpoint.
495    #[tokio::test]
496    async fn no_sample_body_still_produces_request_body_negatives() {
497        let cfg = SelfTestConfig {
498            target_url: "http://127.0.0.1:1".into(),
499            timeout: Duration::from_millis(200),
500            ..Default::default()
501        };
502        // POST with a body content type but no sample (annotator gap).
503        let ops = vec![op("POST", "/x", None, vec![], vec![], vec![])];
504        // No sample_body but request_body_content_type set:
505        let mut ops_fixed = ops;
506        ops_fixed[0].request_body_content_type = Some("application/json".into());
507        let report = run_self_test(&ops_fixed, &cfg).await.expect("client builds");
508        // Both request-body negatives (empty + wrong-type) should fire,
509        // landing in `negative_missed` because the unreachable target
510        // returns no 4xx. The point: count > 0.
511        assert!(
512            report.negative_missed.values().sum::<usize>() >= 2,
513            "expected ≥2 request-body negatives, got {:?}",
514            report.negative_missed
515        );
516    }
517
518    /// Round 16 — operations with a path-param now get a probe even
519    /// when there's no body / required query / required header.
520    /// Previously `/teams/{team-id}` with no other required fields
521    /// produced zero negatives → always "all passing".
522    #[tokio::test]
523    async fn path_param_only_endpoint_produces_a_probe() {
524        let cfg = SelfTestConfig {
525            target_url: "http://127.0.0.1:1".into(),
526            timeout: Duration::from_millis(200),
527            ..Default::default()
528        };
529        let ops = vec![op(
530            "GET",
531            "/teams/{team-id}",
532            None,
533            vec![],
534            vec![],
535            vec![("team-id", "1")],
536        )];
537        let report = run_self_test(&ops, &cfg).await.expect("client builds");
538        let total: usize = report.negative_caught.values().sum::<usize>()
539            + report.negative_missed.values().sum::<usize>();
540        assert!(total >= 1, "expected ≥1 path-param probe, got {:?}", report);
541    }
542
543    #[test]
544    fn json_serialises_report() {
545        let r = SelfTestReport {
546            positive_pass: 1,
547            positive_fail: 0,
548            negative_caught: BTreeMap::new(),
549            negative_missed: BTreeMap::new(),
550            operations: vec![OperationResult {
551                method: "GET".into(),
552                path: "/x".into(),
553                positive: Some(CaseOutcome {
554                    label: "positive".into(),
555                    expected_4xx: false,
556                    actual_status: 200,
557                    passed: true,
558                }),
559                negatives: Vec::new(),
560            }],
561        };
562        let json = serde_json::to_value(&r).expect("serialises");
563        assert_eq!(json["positive_pass"], serde_json::json!(1));
564        assert_eq!(json["operations"][0]["positive"]["actual_status"], serde_json::json!(200));
565    }
566}