- Sync --all with async concurrency, per-host throttling, failure budgets, resumable execution - External ref bundling at fetch time with origin tracking - Cross-alias discovery (--all-aliases) for list and search commands - CI/CD pipeline (.gitlab-ci.yml), cargo-deny config, Dockerfile, install script - Reliability test suite: crash consistency (8 tests), lock contention (3 tests), property-based (4 tests) - Criterion performance benchmarks (5 benchmarks) - Bug fix: doctor --fix now repairs missing index.json when raw.json exists - Bug fix: shared $ref references no longer incorrectly flagged as circular (refs.rs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
4.6 KiB
Rust
134 lines
4.6 KiB
Rust
//! Property-based tests for deterministic behavior.
|
|
//!
|
|
//! Uses proptest to verify that hashing, JSON serialization, and spec construction
|
|
//! produce deterministic results regardless of input data.
|
|
|
|
use proptest::prelude::*;
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
// The SHA-256 hash function is deterministic: same bytes always produce the same hash.
|
|
proptest! {
|
|
#[test]
|
|
fn hash_deterministic(data in proptest::collection::vec(any::<u8>(), 0..10000)) {
|
|
let mut h1 = Sha256::new();
|
|
h1.update(&data);
|
|
let r1 = format!("{:x}", h1.finalize());
|
|
|
|
let mut h2 = Sha256::new();
|
|
h2.update(&data);
|
|
let r2 = format!("{:x}", h2.finalize());
|
|
|
|
prop_assert_eq!(r1, r2);
|
|
}
|
|
}
|
|
|
|
// Two specs with the same paths (inserted in different order) should produce
|
|
// identical canonical JSON after a parse-serialize roundtrip through serde_json::Value.
|
|
//
|
|
// serde_json::Value uses BTreeMap for objects, so key ordering is deterministic
|
|
// regardless of insertion order.
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(20))]
|
|
#[test]
|
|
fn index_ordering_deterministic(
|
|
// Use proptest to pick a permutation index
|
|
perm_seed in 0..40320u32, // 8! = 40320
|
|
) {
|
|
let paths: Vec<(&str, &str, &str)> = vec![
|
|
("/pets", "get", "List pets"),
|
|
("/pets", "post", "Create pet"),
|
|
("/pets/{petId}", "get", "Get pet"),
|
|
("/pets/{petId}", "delete", "Delete pet"),
|
|
("/users", "get", "List users"),
|
|
("/users/{userId}", "get", "Get user"),
|
|
("/orders", "get", "List orders"),
|
|
("/orders", "post", "Create order"),
|
|
];
|
|
|
|
// Build the spec with original order
|
|
let spec1 = build_spec_from_paths(&paths);
|
|
|
|
// Generate a deterministic permutation from the seed
|
|
let shuffled = permute(&paths, perm_seed);
|
|
let spec2 = build_spec_from_paths(&shuffled);
|
|
|
|
// After roundtripping through serde_json::Value (BTreeMap ordering),
|
|
// both should produce identical canonical JSON.
|
|
let val1: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec1).unwrap()).unwrap();
|
|
let val2: serde_json::Value = serde_json::from_str(&serde_json::to_string(&spec2).unwrap()).unwrap();
|
|
let canonical1 = serde_json::to_string(&val1).unwrap();
|
|
let canonical2 = serde_json::to_string(&val2).unwrap();
|
|
|
|
prop_assert_eq!(canonical1, canonical2);
|
|
}
|
|
}
|
|
|
|
/// Deterministic permutation using factorial number system (no rand dependency).
|
|
fn permute<T: Clone>(items: &[T], mut seed: u32) -> Vec<T> {
|
|
let mut remaining: Vec<T> = items.to_vec();
|
|
let mut result = Vec::with_capacity(items.len());
|
|
|
|
for i in (1..=items.len()).rev() {
|
|
let idx = (seed % i as u32) as usize;
|
|
seed /= i as u32;
|
|
result.push(remaining.remove(idx));
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Build a minimal OpenAPI spec from a list of (path, method, summary) tuples.
|
|
fn build_spec_from_paths(paths: &[(&str, &str, &str)]) -> serde_json::Value {
|
|
let mut path_map = serde_json::Map::new();
|
|
|
|
for (path, method, summary) in paths {
|
|
let path_entry = path_map
|
|
.entry(path.to_string())
|
|
.or_insert_with(|| json!({}));
|
|
path_entry[method] = json!({
|
|
"summary": summary,
|
|
"responses": { "200": { "description": "OK" } }
|
|
});
|
|
}
|
|
|
|
json!({
|
|
"openapi": "3.0.3",
|
|
"info": { "title": "Test", "version": "1.0.0" },
|
|
"paths": path_map
|
|
})
|
|
}
|
|
|
|
// Verify that JSON canonical form is stable across parse-serialize cycles.
|
|
proptest! {
|
|
#[test]
|
|
fn json_roundtrip_stable(
|
|
key1 in "[a-z]{1,10}",
|
|
key2 in "[a-z]{1,10}",
|
|
val1 in any::<i64>(),
|
|
val2 in any::<i64>(),
|
|
) {
|
|
let obj = json!({ key1.clone(): val1, key2.clone(): val2 });
|
|
let s1 = serde_json::to_string(&obj).unwrap();
|
|
let reparsed: serde_json::Value = serde_json::from_str(&s1).unwrap();
|
|
let s2 = serde_json::to_string(&reparsed).unwrap();
|
|
|
|
// Parse -> serialize should be idempotent
|
|
prop_assert_eq!(s1, s2);
|
|
}
|
|
}
|
|
|
|
// Hash output format is always "sha256:" followed by 64 hex chars.
|
|
proptest! {
|
|
#[test]
|
|
fn hash_format_consistent(data in proptest::collection::vec(any::<u8>(), 0..1000)) {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&data);
|
|
let hash = format!("sha256:{:x}", hasher.finalize());
|
|
|
|
prop_assert!(hash.starts_with("sha256:"));
|
|
// SHA-256 hex is 64 chars
|
|
prop_assert_eq!(hash.len(), 7 + 64); // "sha256:" + 64 hex digits
|
|
}
|
|
}
|