mockforge_foundation/conformance_violations.rs
1//! Server-side conformance violation tracking.
2//!
3//! Issue #79 round 12 — Srikanth's ask: "It would be good if mockforge tui
4//! have a separate section for conformance failures on the incoming
5//! requests to the mockforge server which has spec violation from the
6//! Server Side point of view, that way I can cross check Server Side
7//! Info with our proxy and understand the diff."
8//!
9//! The OpenAPI router already rejects requests that violate the loaded
10//! spec (status 400/422). This module captures every such rejection into
11//! a bounded ring buffer so the TUI / admin API can surface them
12//! without scraping logs.
13//!
14//! Storage is best-effort, in-memory, and bounded — under sustained
15//! WAF / load-test traffic we keep only the most recent N violations.
16
17use chrono::{DateTime, Utc};
18use once_cell::sync::Lazy;
19use parking_lot::Mutex;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, VecDeque};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24/// A single server-side conformance violation captured at the OpenAPI
25/// router. Mirrors `ConformanceViolation` semantics from the bench-side
26/// client validator so consumers can use the same dashboards.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServerConformanceViolation {
29 /// When the request was rejected.
30 pub timestamp: DateTime<Utc>,
31 /// HTTP method (uppercase).
32 pub method: String,
33 /// Spec-template path the request matched (e.g. `/users/{id}`).
34 pub path: String,
35 /// Client IP if available, else `"unknown"`.
36 pub client_ip: String,
37 /// HTTP status the server replied with (typically 400 or 422).
38 pub status: u16,
39 /// Short, human-readable reason — derived from the validator error.
40 pub reason: String,
41 /// Spec category the violation falls into (`"parameters"`,
42 /// `"request-body"`, `"headers"`, etc.). Empty if the validator
43 /// couldn't classify.
44 pub category: String,
45 /// Round 30 — number of times this signature has been observed.
46 /// Always `1` in FIFO mode (the default). In unique-buffer mode
47 /// (`MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true`) every duplicate
48 /// hit bumps this counter on the existing entry instead of
49 /// consuming a new buffer slot. Defaults to `1` when deserialising
50 /// older payloads that don't carry the field.
51 #[serde(default = "one")]
52 pub occurrences: u32,
53 /// Round 36 (#876) — mockforge version the *client* (the bench
54 /// driver) was running when it sent the request, as read from the
55 /// `X-Mockforge-Client-Version` header. `None` when the inbound
56 /// request didn't carry the header (older client, real proxy
57 /// traffic, etc.). Lets users cross-correlate a client-side
58 /// `CaseCapture` JSONL line with the matching server-side
59 /// violation when both sides log against the same code base.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub client_mockforge_version: Option<String>,
62 /// Round 36 (#876) — wall-clock timestamp the *client* stamped on
63 /// its `CaseCapture`, as read from the `X-Mockforge-Client-Sent-At`
64 /// header (RFC3339). Server-side `timestamp` is when the
65 /// violation was *received*; this is when the probe was *sent*.
66 /// Grep both for the same value to line up client + server
67 /// records of the same probe.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub client_sent_at: Option<DateTime<Utc>>,
70 /// Round 44 (#79) — short human-readable summary of `reason`, for
71 /// dashboards / report tables that don't want to display the full
72 /// JSON-shaped validator error. Built once at insertion time from
73 /// `reason` via `summarize_reason`; empty when the caller didn't
74 /// supply one. Older payloads without this field deserialise as
75 /// empty so consumers can fall back to `reason`.
76 ///
77 /// Srikanth on 0.3.188: "What is the difference between
78 /// Validation error: and errors both the content seems similar
79 /// with few differences here and there... The reason I am asking
80 /// is both the errors are overwhelming to view."
81 #[serde(default)]
82 pub summary: String,
83 /// Round 47 (#79) — Srikanth on 0.3.191: "I will not know other
84 /// violations until previous ones are fixed. Is it possible to
85 /// give a option to show all the violation irrespective of the
86 /// order both on client request logs and mockforge tui logs".
87 /// `category` already records the priority-winning location for
88 /// dashboards that filter by single category; `categories` lists
89 /// EVERY distinct location the validator's `details[]` payload
90 /// surfaced (e.g. `["query","request-body"]` for a POST that
91 /// failed both a query enum AND a required body field). Empty
92 /// when the validator didn't embed a structured `details` map.
93 /// Older clients deserialise this as empty and fall back to
94 /// `category` alone.
95 #[serde(default)]
96 pub categories: Vec<String>,
97}
98
99/// Header set by the bench client (round 36, #876) carrying the
100/// mockforge version that sent the request.
101pub const CLIENT_VERSION_HEADER: &str = "x-mockforge-client-version";
102
103/// Header set by the bench client (round 36, #876) carrying the
104/// RFC3339 timestamp the request was sent at.
105pub const CLIENT_SENT_AT_HEADER: &str = "x-mockforge-client-sent-at";
106
107/// Parse the client-stamp headers off a raw `(name, value)` lookup
108/// function. Accepts a closure so the same helper can read from
109/// `axum::http::HeaderMap`, `reqwest::header::HeaderMap`, or a plain
110/// `HashMap<String, String>` without forcing a particular type on
111/// the caller. Header names are looked up case-insensitively.
112pub fn read_client_stamps<F>(get: F) -> (Option<String>, Option<DateTime<Utc>>)
113where
114 F: Fn(&str) -> Option<String>,
115{
116 let version = get(CLIENT_VERSION_HEADER).filter(|s| !s.is_empty());
117 let sent_at = get(CLIENT_SENT_AT_HEADER)
118 .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
119 .map(|d| d.with_timezone(&Utc));
120 (version, sent_at)
121}
122
123fn one() -> u32 {
124 1
125}
126
127const DEFAULT_BUFFER_SIZE: usize = 256;
128
129/// Round 29 — Srikanth on 0.3.172 had 10,145 violations seen but only
130/// 114 unique entries in his export, because the in-memory ring buffer
131/// caps at 256. For long-running runs against large specs (vCenter,
132/// Microsoft Graph) that fills quickly. Override via
133/// `MOCKFORGE_CONFORMANCE_BUFFER_SIZE` so users can raise it without
134/// recompiling. Capped at 64k to keep peak memory bounded.
135fn effective_buffer_size() -> usize {
136 let cap: usize = 64 * 1024;
137 std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE")
138 .ok()
139 .and_then(|s| s.parse::<usize>().ok())
140 .filter(|n| *n > 0)
141 .map(|n| n.min(cap))
142 .unwrap_or(DEFAULT_BUFFER_SIZE)
143}
144
145/// Round 30 — Srikanth on 0.3.173: "Can we have this buffer for unique
146/// violation as opposed to duplicate violation. If this buffer size
147/// doesn't discount duplicates then again we will run out of buffer
148/// easily when more and more requests come to the server."
149///
150/// `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true` switches storage from
151/// FIFO to dedup-by-signature: every duplicate of an already-buffered
152/// (method, path, status, category, reason) hits its existing entry
153/// and bumps `occurrences` instead of consuming a new slot. The buffer
154/// fills only as fast as unique signatures arrive — so at 256 entries
155/// a vCenter spec with ~150 unique violation kinds will hold every
156/// kind even under 10M+ requests, instead of being clobbered by the
157/// most common offender.
158fn unique_mode_enabled() -> bool {
159 std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE")
160 .ok()
161 .map(|s| matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
162 .unwrap_or(false)
163}
164
165fn signature(v: &ServerConformanceViolation) -> String {
166 format!("{}|{}|{}|{}|{}", v.method, v.path, v.status, v.category, v.reason)
167}
168
169/// FIFO buffer (default mode). Each violation consumes one slot,
170/// oldest evicted when full.
171static VIOLATIONS: Lazy<Mutex<VecDeque<ServerConformanceViolation>>> =
172 Lazy::new(|| Mutex::new(VecDeque::with_capacity(effective_buffer_size())));
173
174/// Unique-mode buffer: signature → entry (with bumped `occurrences`)
175/// plus a `VecDeque<signature>` for insertion-order eviction. Only
176/// touched when `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE` is enabled.
177struct UniqueBuffer {
178 by_sig: HashMap<String, ServerConformanceViolation>,
179 order: VecDeque<String>,
180}
181
182impl UniqueBuffer {
183 fn new() -> Self {
184 Self {
185 by_sig: HashMap::new(),
186 order: VecDeque::new(),
187 }
188 }
189
190 fn record(&mut self, mut v: ServerConformanceViolation, cap: usize) {
191 let sig = signature(&v);
192 if let Some(existing) = self.by_sig.get_mut(&sig) {
193 existing.occurrences = existing.occurrences.saturating_add(1);
194 existing.timestamp = v.timestamp;
195 return;
196 }
197 v.occurrences = 1;
198 while self.order.len() >= cap {
199 if let Some(old) = self.order.pop_front() {
200 self.by_sig.remove(&old);
201 } else {
202 break;
203 }
204 }
205 self.order.push_back(sig.clone());
206 self.by_sig.insert(sig, v);
207 }
208
209 fn snapshot(&self) -> Vec<ServerConformanceViolation> {
210 self.order.iter().rev().filter_map(|s| self.by_sig.get(s).cloned()).collect()
211 }
212
213 fn len(&self) -> usize {
214 self.order.len()
215 }
216
217 fn clear(&mut self) {
218 self.by_sig.clear();
219 self.order.clear();
220 }
221}
222
223static UNIQUE_VIOLATIONS: Lazy<Mutex<UniqueBuffer>> = Lazy::new(|| Mutex::new(UniqueBuffer::new()));
224
225/// Lifetime count of violations recorded since process start (Issue #79
226/// round 15). The ring buffer only keeps the most recent
227/// `DEFAULT_BUFFER_SIZE`; this counter answers Srikanth's "I sent 656k
228/// requests but only see 256" — the 256 is the buffer cap, this is the
229/// true total seen.
230static TOTAL_SEEN: AtomicU64 = AtomicU64::new(0);
231
232/// Lifetime count of requests that *passed* the spec validator (round
233/// 17.1). Bumped on the `Ok(())` branch of
234/// `run_validation_with_recording`. Lets the TUI display
235/// "X conformant / Y violations" instead of just one side.
236static TOTAL_OK: AtomicU64 = AtomicU64::new(0);
237
238/// Bump the conformant-request counter. Called from the validator's
239/// success path. Bench code can call it directly when wiring its own
240/// counters too.
241pub fn record_ok() {
242 TOTAL_OK.fetch_add(1, Ordering::Relaxed);
243}
244
245/// Lifetime total of requests that passed the spec validator.
246pub fn total_ok() -> u64 {
247 TOTAL_OK.load(Ordering::Relaxed)
248}
249
250/// Record a violation. Old entries are dropped when the buffer is full
251/// (FIFO by default; signature-deduped under
252/// `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true`). Cheap enough to call
253/// from the hot path — uses a parking_lot Mutex which is uncontended in
254/// steady state.
255pub fn record(mut violation: ServerConformanceViolation) {
256 TOTAL_SEEN.fetch_add(1, Ordering::Relaxed);
257 if violation.summary.is_empty() {
258 violation.summary = summarize_reason(&violation.reason);
259 }
260 // Round 47 (#79) — derive the full list of distinct categories
261 // from the validator's `details[]` payload at insertion time so
262 // consumers can show "what ALL is broken" at once. Empty array
263 // when the reason has no structured payload to walk.
264 if violation.categories.is_empty() {
265 violation.categories = all_categories_from_reason(&violation.reason);
266 }
267 let cap = effective_buffer_size();
268 if unique_mode_enabled() {
269 UNIQUE_VIOLATIONS.lock().record(violation, cap);
270 return;
271 }
272 if violation.occurrences == 0 {
273 violation.occurrences = 1;
274 }
275 let mut buf = VIOLATIONS.lock();
276 while buf.len() >= cap {
277 buf.pop_front();
278 }
279 buf.push_back(violation);
280}
281
282/// Build a short, human-readable summary of a validator-error `reason`
283/// string. Round 44 (#79) — Srikanth on 0.3.188: the full `reason`
284/// embeds both a `details` array AND a redundant `errors` string array
285/// of the same content with slightly different prose, which made the
286/// violations table hard to scan. The summary collapses the violator
287/// list to a single line shaped `<N> <category> violation(s): <name>
288/// (<rule>), <name> (<rule>)...` so the table can show that at a
289/// glance and keep the full `reason` JSON only for tooling that needs
290/// the structured form. Empty when the reason doesn't carry a parsable
291/// `"details": [...]` payload (the older heuristic fallback path).
292pub fn summarize_reason(reason: &str) -> String {
293 use serde_json::Value;
294
295 // Pull out the `{...}` JSON the validator embeds inside the reason
296 // prose (e.g. `Validation error: {"details":[...],"errors":[...]}`).
297 let json_start = reason.find('{');
298 let parsed: Option<Value> = json_start
299 .and_then(|i| serde_json::from_str(reason[i..].trim()).ok())
300 .or_else(|| serde_json::from_str(reason).ok());
301
302 let details = parsed
303 .as_ref()
304 .and_then(|v| v.get("details"))
305 .and_then(|v| v.as_array())
306 .cloned()
307 .unwrap_or_default();
308
309 if details.is_empty() {
310 return String::new();
311 }
312
313 // Bucket details by their `path` prefix (query / body / header /
314 // cookie / parameters) so we report a sensible category even when a
315 // request has violations across multiple locations.
316 let mut by_loc: std::collections::BTreeMap<String, Vec<(String, String)>> =
317 std::collections::BTreeMap::new();
318
319 for d in &details {
320 let path = d.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
321 let code = d.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();
322 let (loc, name) = match path.split_once('.') {
323 Some((l, n)) => (l.to_string(), n.to_string()),
324 None if !path.is_empty() => (path.clone(), String::new()),
325 _ => ("body".to_string(), String::new()),
326 };
327 let rule = match code.as_str() {
328 "schema_validation" => infer_rule(d),
329 "required" => "required".to_string(),
330 other => other.to_string(),
331 };
332 // Round 46 (#79) — Srikanth on 0.3.189: "Here Not able to
333 // understand what is actual value in the request and what is
334 // expected." Pull the got/expected pair out of the validator's
335 // message text so the summary becomes self-explaining instead
336 // of a one-word rule label.
337 let detail = infer_got_expected(d, &rule);
338 let labelled_rule = match detail {
339 Some(d) => format!("{}: {}", rule, d),
340 None => rule,
341 };
342 by_loc.entry(loc).or_default().push((name, labelled_rule));
343 }
344
345 let total = details.len();
346 // Round 45 (#79) — match `classify_validation_reason`'s priority
347 // order (query > header > cookie > path > body) so the summary's
348 // `<category>` label agrees with the violation's `category` field.
349 // BTreeMap's alphabetical order was producing "request-body
350 // violation(s)" when category was actually "query".
351 let primary_loc = ["query", "header", "cookie", "path", "body"]
352 .iter()
353 .find(|loc| by_loc.contains_key(**loc))
354 .map(|s| s.to_string())
355 .or_else(|| by_loc.keys().next().cloned())
356 .unwrap_or_else(|| "validation".to_string());
357 let primary_label = match primary_loc.as_str() {
358 "query" => "query",
359 "header" => "header",
360 "cookie" => "cookie",
361 "path" => "path parameter",
362 "body" => "request-body",
363 other => other,
364 };
365
366 let mut items: Vec<String> = Vec::new();
367 for (loc, names) in &by_loc {
368 for (name, rule) in names {
369 let head = if name.is_empty() {
370 loc.clone()
371 } else {
372 format!("{}.{}", loc, name)
373 };
374 if rule.is_empty() {
375 items.push(head);
376 } else {
377 items.push(format!("{} ({})", head, rule));
378 }
379 }
380 }
381
382 // Cap the visible items so the summary stays scannable; the full
383 // detail list still lives in `reason` for tooling.
384 const MAX_VISIBLE: usize = 5;
385 let visible: Vec<String> = items.iter().take(MAX_VISIBLE).cloned().collect();
386 let suffix = if items.len() > MAX_VISIBLE {
387 format!(", +{} more", items.len() - MAX_VISIBLE)
388 } else {
389 String::new()
390 };
391
392 format!("{} {} violation(s): {}{}", total, primary_label, visible.join(", "), suffix)
393}
394
395/// Round 46 (#79) — pull the (got, expected) pair out of the validator's
396/// freeform `message` text so the summary can show actual + expected
397/// values inline instead of just a bare rule name. Examples:
398/// - `"test-value" is not one of "1" or "2"` → `got "test-value", expected one of "1", "2"`
399/// - `"abc" is not of type "boolean"` → `got "abc", expected type "boolean"`
400/// - `"name" is a required property` → `expected property "name"`
401/// - Returns None when the message doesn't match any known shape; the
402/// summary then falls back to the bare rule label.
403fn infer_got_expected(detail: &serde_json::Value, rule: &str) -> Option<String> {
404 let msg = detail.get("message").and_then(|v| v.as_str()).unwrap_or("");
405 if msg.is_empty() {
406 return None;
407 }
408 let extract_quoted = |s: &str| -> Vec<String> {
409 let mut out = Vec::new();
410 let bytes = s.as_bytes();
411 let mut i = 0;
412 while i < bytes.len() {
413 if bytes[i] == b'"' {
414 let start = i + 1;
415 let mut j = start;
416 while j < bytes.len() && bytes[j] != b'"' {
417 j += 1;
418 }
419 if j < bytes.len() {
420 out.push(s[start..j].to_string());
421 i = j + 1;
422 continue;
423 }
424 }
425 i += 1;
426 }
427 out
428 };
429 let quoted = extract_quoted(msg);
430
431 match rule {
432 "enum" => {
433 if quoted.len() < 2 {
434 return None;
435 }
436 let got = "ed[0];
437 let expected: Vec<String> = quoted[1..].iter().map(|s| format!("\"{}\"", s)).collect();
438 Some(format!("got \"{}\", expected one of {}", got, expected.join(", ")))
439 }
440 "type" => {
441 if quoted.len() < 2 {
442 return None;
443 }
444 Some(format!("got \"{}\", expected type \"{}\"", quoted[0], quoted[1]))
445 }
446 "required" => {
447 // `"<field>" is a required property` — the only quoted string is the field name.
448 quoted.first().map(|name| format!("missing required field \"{}\"", name))
449 }
450 "min" | "max" => {
451 // Bound shapes like `<N> is less than minimum <M>`. The
452 // numbers are usually unquoted, so try a regex-style
453 // extraction on the prose.
454 let lower = msg.to_lowercase();
455 let kw = if rule == "min" {
456 ["less than minimum", "less than"]
457 } else {
458 ["greater than maximum", "greater than"]
459 };
460 for k in kw {
461 if let Some(idx) = lower.find(k) {
462 let head = msg[..idx].trim();
463 let tail = msg[idx + k.len()..].trim();
464 return Some(format!("got {}, {} {}", head, k, tail));
465 }
466 }
467 None
468 }
469 "pattern" => {
470 if quoted.len() >= 2 {
471 Some(format!("got \"{}\", expected pattern \"{}\"", quoted[0], quoted[1]))
472 } else {
473 None
474 }
475 }
476 _ => None,
477 }
478}
479
480/// Round 47 (#79) — walk the validator's `details[]` payload embedded
481/// in `reason` and emit ALL distinct location categories. Used to
482/// populate `ServerConformanceViolation::categories` at record time so
483/// consumers can show every category at once instead of just the
484/// priority winner (`category` field). Order is the same priority
485/// chain the classifier uses (query > header > cookie > path > body)
486/// for stable rendering, but every category is included.
487pub fn all_categories_from_reason(reason: &str) -> Vec<String> {
488 use serde_json::Value;
489 let json_start = reason.find('{');
490 let parsed: Option<Value> = json_start
491 .and_then(|i| serde_json::from_str(reason[i..].trim()).ok())
492 .or_else(|| serde_json::from_str(reason).ok());
493 let details = parsed
494 .as_ref()
495 .and_then(|v| v.get("details"))
496 .and_then(|v| v.as_array())
497 .cloned()
498 .unwrap_or_default();
499 let mut seen = std::collections::BTreeSet::new();
500 for d in &details {
501 let path = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
502 let loc = match path.split_once('.') {
503 Some((l, _)) => l,
504 None if !path.is_empty() => path,
505 _ => continue,
506 };
507 let canonical = match loc {
508 "query" => "query",
509 "header" => "headers",
510 "cookie" => "cookies",
511 "path" => "parameters",
512 "body" => "request-body",
513 _ => continue,
514 };
515 seen.insert(canonical.to_string());
516 }
517 let order = ["query", "headers", "cookies", "parameters", "request-body"];
518 let mut out = Vec::new();
519 for cat in order {
520 if seen.remove(cat) {
521 out.push(cat.to_string());
522 }
523 }
524 out.extend(seen);
525 out
526}
527
528/// Read a single `details[]` entry and produce a short rule label like
529/// `enum` / `type` / `min` / `max` / `pattern` for the summary. Falls
530/// back to the validator's own `message` prose when no rule is
531/// recognisable (defensive against future validator wording).
532fn infer_rule(detail: &serde_json::Value) -> String {
533 let msg = detail.get("message").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
534 if msg.contains("is not one of") {
535 return "enum".to_string();
536 }
537 if msg.contains("not of type") || msg.contains("expected type") {
538 return "type".to_string();
539 }
540 if msg.contains("less than") || msg.contains("minimum") {
541 return "min".to_string();
542 }
543 if msg.contains("greater than") || msg.contains("maximum") {
544 return "max".to_string();
545 }
546 if msg.contains("pattern") {
547 return "pattern".to_string();
548 }
549 if msg.contains("required") {
550 return "required".to_string();
551 }
552 "schema".to_string()
553}
554
555/// Snapshot of the buffered violations, newest first.
556pub fn snapshot() -> Vec<ServerConformanceViolation> {
557 if unique_mode_enabled() {
558 UNIQUE_VIOLATIONS.lock().snapshot()
559 } else {
560 let buf = VIOLATIONS.lock();
561 buf.iter().rev().cloned().collect()
562 }
563}
564
565/// Number of violations currently buffered (≤ `effective_buffer_size`).
566pub fn len() -> usize {
567 if unique_mode_enabled() {
568 UNIQUE_VIOLATIONS.lock().len()
569 } else {
570 VIOLATIONS.lock().len()
571 }
572}
573
574/// Lifetime total of violations recorded since process start, including
575/// ones the ring buffer has since evicted.
576pub fn total_seen() -> u64 {
577 TOTAL_SEEN.load(Ordering::Relaxed)
578}
579
580/// Clear both buffers and reset lifetime counters. Primarily for
581/// tests and TUI "reset" actions.
582pub fn clear() {
583 VIOLATIONS.lock().clear();
584 UNIQUE_VIOLATIONS.lock().clear();
585 TOTAL_SEEN.store(0, Ordering::Relaxed);
586 TOTAL_OK.store(0, Ordering::Relaxed);
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 fn v(method: &str, status: u16) -> ServerConformanceViolation {
594 ServerConformanceViolation {
595 timestamp: Utc::now(),
596 method: method.to_string(),
597 path: "/test".into(),
598 client_ip: "127.0.0.1".into(),
599 status,
600 reason: "test".into(),
601 category: "parameters".into(),
602 occurrences: 1,
603 client_mockforge_version: None,
604 client_sent_at: None,
605 summary: String::new(),
606 categories: Vec::new(),
607 }
608 }
609
610 /// Round 36 (#876) — `read_client_stamps` returns both fields
611 /// when the headers are present and RFC3339-parsable.
612 #[test]
613 fn read_client_stamps_roundtrips_when_headers_present() {
614 let stamped_at = "2026-06-17T12:34:56Z";
615 let (version, sent_at) = read_client_stamps(|name| match name {
616 CLIENT_VERSION_HEADER => Some("0.3.183".to_string()),
617 CLIENT_SENT_AT_HEADER => Some(stamped_at.to_string()),
618 _ => None,
619 });
620 assert_eq!(version.as_deref(), Some("0.3.183"));
621 let sent_at = sent_at.expect("should parse RFC3339 timestamp");
622 assert_eq!(sent_at.to_rfc3339(), "2026-06-17T12:34:56+00:00");
623 }
624
625 /// Missing or malformed headers should yield `None`, not panic
626 /// or fall back to "now" (we don't want to fabricate timestamps).
627 #[test]
628 fn read_client_stamps_returns_none_when_headers_absent_or_garbage() {
629 let (v, s) = read_client_stamps(|_| None);
630 assert!(v.is_none());
631 assert!(s.is_none());
632
633 let (v, s) = read_client_stamps(|name| {
634 if name == CLIENT_SENT_AT_HEADER {
635 Some("not-a-timestamp".to_string())
636 } else {
637 None
638 }
639 });
640 assert!(v.is_none());
641 assert!(s.is_none(), "garbage timestamp must not be invented");
642
643 // Empty version string treated as absent.
644 let (v, _) = read_client_stamps(|name| {
645 if name == CLIENT_VERSION_HEADER {
646 Some(String::new())
647 } else {
648 None
649 }
650 });
651 assert!(v.is_none());
652 }
653
654 #[test]
655 fn record_and_snapshot_in_lifo_order() {
656 clear();
657 record(v("GET", 400));
658 record(v("POST", 422));
659 let snap = snapshot();
660 assert_eq!(snap.len(), 2);
661 // newest first
662 assert_eq!(snap[0].method, "POST");
663 assert_eq!(snap[1].method, "GET");
664 }
665
666 #[test]
667 fn buffer_drops_oldest_at_capacity() {
668 clear();
669 for i in 0..(DEFAULT_BUFFER_SIZE + 50) {
670 let mut entry = v("GET", 400);
671 entry.reason = format!("{i}");
672 record(entry);
673 }
674 assert_eq!(len(), DEFAULT_BUFFER_SIZE);
675 let snap = snapshot();
676 // newest is the last one we pushed
677 assert_eq!(snap[0].reason, format!("{}", DEFAULT_BUFFER_SIZE + 50 - 1));
678 // oldest still present is index 50 (the first 50 got dropped)
679 assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].reason, format!("{}", 50));
680 }
681
682 /// Round 29 — `MOCKFORGE_CONFORMANCE_BUFFER_SIZE` env var
683 /// overrides the default 256 cap. Tagged `#[ignore]` because it
684 /// mutates a process-wide env var that races with the other
685 /// tests in this module (which call `record()` → which reads
686 /// the same env var). Run explicitly with
687 /// `cargo test -p mockforge-foundation -- --ignored
688 /// effective_buffer_size_respects_env_var --test-threads=1`.
689 #[test]
690 #[ignore]
691 fn effective_buffer_size_respects_env_var() {
692 let original = std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE").ok();
693
694 // SAFETY: process-wide env mutation is unsound under multi-
695 // threaded test runs; this test is gated with `#[ignore]` to
696 // force serial execution by the developer when needed.
697 unsafe {
698 std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "1000");
699 }
700 assert_eq!(effective_buffer_size(), 1000);
701
702 unsafe {
703 std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "0");
704 }
705 assert_eq!(effective_buffer_size(), DEFAULT_BUFFER_SIZE, "zero falls back to default");
706
707 unsafe {
708 std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "garbage");
709 }
710 assert_eq!(
711 effective_buffer_size(),
712 DEFAULT_BUFFER_SIZE,
713 "unparsable falls back to default"
714 );
715
716 unsafe {
717 std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "999999");
718 }
719 assert_eq!(effective_buffer_size(), 64 * 1024, "clamped to 64k");
720
721 // Restore
722 unsafe {
723 match original {
724 Some(v) => std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", v),
725 None => std::env::remove_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE"),
726 }
727 }
728 }
729
730 /// Round 30 — unique-mode buffer dedups duplicate signatures and
731 /// bumps `occurrences` instead of consuming new slots. Direct
732 /// `UniqueBuffer::record` call avoids the global env-var read,
733 /// so this test stays threadsafe without `#[ignore]`.
734 #[test]
735 fn unique_buffer_dedups_by_signature_and_counts_occurrences() {
736 let mut buf = UniqueBuffer::new();
737 for _ in 0..10_000 {
738 buf.record(v("GET", 400), 256);
739 }
740 // 10k identical violations → 1 slot used, occurrences == 10000.
741 assert_eq!(buf.len(), 1);
742 let snap = buf.snapshot();
743 assert_eq!(snap.len(), 1);
744 assert_eq!(snap[0].occurrences, 10_000);
745 assert_eq!(snap[0].method, "GET");
746 }
747
748 /// Round 30 — different (method, path, status, category, reason)
749 /// tuples occupy distinct slots; identical tuples coalesce.
750 #[test]
751 fn unique_buffer_distinguishes_distinct_signatures() {
752 let mut buf = UniqueBuffer::new();
753 // 3 distinct signatures × 100 hits each
754 for _ in 0..100 {
755 buf.record(v("GET", 400), 256);
756 buf.record(v("POST", 422), 256);
757 let mut other = v("GET", 400);
758 other.reason = "different".into();
759 buf.record(other, 256);
760 }
761 assert_eq!(buf.len(), 3);
762 let snap = buf.snapshot();
763 assert_eq!(snap.len(), 3);
764 for entry in &snap {
765 assert_eq!(entry.occurrences, 100, "each signature seen 100×");
766 }
767 }
768
769 /// Round 30 — unique mode still evicts when distinct-signature
770 /// count exceeds the cap. Eviction is FIFO over insertion order
771 /// (NOT recency-of-hit), matching how the regular ring buffer
772 /// reads.
773 #[test]
774 fn unique_buffer_evicts_oldest_signature_at_capacity() {
775 let mut buf = UniqueBuffer::new();
776 let cap = 4;
777 for i in 0..(cap + 3) {
778 let mut entry = v("GET", 400);
779 entry.reason = format!("kind-{i}");
780 buf.record(entry, cap);
781 }
782 assert_eq!(buf.len(), cap);
783 let snap = buf.snapshot();
784 // newest first; signatures 0..2 evicted, 3..6 retained
785 let kinds: Vec<&str> = snap.iter().map(|e| e.reason.as_str()).collect();
786 assert_eq!(kinds, vec!["kind-6", "kind-5", "kind-4", "kind-3"]);
787 }
788}