Wave 7: Phase 2 features - sync --all, external refs, cross-alias discovery, CI/CD, reliability tests (bd-1ky, bd-1bp, bd-1rk, bd-1lj, bd-gvr, bd-1x5)
- 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>
This commit is contained in:
168
tests/crash_consistency_test.rs
Normal file
168
tests/crash_consistency_test.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Crash consistency tests for the cache write protocol.
|
||||
//!
|
||||
//! Simulates partial writes at each stage of the crash-consistent write protocol
|
||||
//! and verifies that the read protocol detects corruption and doctor --fix repairs it.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use std::fs;
|
||||
|
||||
/// Helper: fetch a spec into the test environment so we have valid cache state.
|
||||
fn setup_with_cached_spec(env: &helpers::TestEnv) {
|
||||
helpers::fetch_fixture(env, "petstore.json", "petstore");
|
||||
}
|
||||
|
||||
/// When raw.json exists but meta.json is missing (crash before commit marker),
|
||||
/// load_index should fail with AliasNotFound (meta is the commit marker).
|
||||
#[test]
|
||||
fn test_crash_before_meta_write() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let meta_path = alias_dir.join("meta.json");
|
||||
|
||||
// Simulate crash: remove meta.json (the commit marker)
|
||||
assert!(meta_path.exists(), "meta.json should exist after fetch");
|
||||
fs::remove_file(&meta_path).expect("failed to remove meta.json");
|
||||
|
||||
// Read should fail — no commit marker
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.failure();
|
||||
}
|
||||
|
||||
/// When meta.json exists but index.json is corrupted (partial write),
|
||||
/// load_index should fail with CacheIntegrity (hash mismatch).
|
||||
#[test]
|
||||
fn test_crash_corrupted_index() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let index_path = alias_dir.join("index.json");
|
||||
|
||||
// Corrupt the index file
|
||||
fs::write(&index_path, b"{ corrupted }").expect("failed to corrupt index");
|
||||
|
||||
// Read should fail — hash mismatch
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.failure();
|
||||
}
|
||||
|
||||
/// When meta.json exists but raw.json is corrupted,
|
||||
/// load_raw should fail with CacheIntegrity (hash mismatch).
|
||||
/// Note: list/search only need index.json, so we test via show which needs raw.json.
|
||||
#[test]
|
||||
fn test_crash_corrupted_raw_json() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let raw_path = alias_dir.join("raw.json");
|
||||
|
||||
// Corrupt raw.json
|
||||
fs::write(&raw_path, b"{ corrupted }").expect("failed to corrupt raw.json");
|
||||
|
||||
// show needs raw.json for $ref expansion → should fail
|
||||
let result = helpers::run_cmd(
|
||||
&env,
|
||||
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
|
||||
);
|
||||
result.failure();
|
||||
}
|
||||
|
||||
/// Doctor --fix should repair a missing index by rebuilding from raw.json.
|
||||
#[test]
|
||||
fn test_doctor_fix_repairs_missing_index() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let index_path = alias_dir.join("index.json");
|
||||
|
||||
// Remove index.json
|
||||
fs::remove_file(&index_path).expect("failed to remove index.json");
|
||||
|
||||
// Doctor --fix should repair
|
||||
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
|
||||
result.success();
|
||||
|
||||
// After fix, list should work again
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.success();
|
||||
}
|
||||
|
||||
/// Doctor --fix should repair a corrupted index (hash mismatch) by rebuilding.
|
||||
#[test]
|
||||
fn test_doctor_fix_repairs_corrupted_index() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let index_path = alias_dir.join("index.json");
|
||||
|
||||
// Corrupt the index
|
||||
fs::write(&index_path, b"[]").expect("failed to corrupt index");
|
||||
|
||||
// Doctor --fix should detect and repair
|
||||
let result = helpers::run_cmd(&env, &["doctor", "--fix", "--robot"]);
|
||||
result.success();
|
||||
|
||||
// After fix, list should work
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.success();
|
||||
}
|
||||
|
||||
/// When meta.json has a generation mismatch with index.json, read should fail.
|
||||
#[test]
|
||||
fn test_generation_mismatch_detected() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
let meta_path = alias_dir.join("meta.json");
|
||||
|
||||
// Tamper with generation in meta.json
|
||||
let meta_bytes = fs::read(&meta_path).expect("failed to read meta");
|
||||
let mut meta: serde_json::Value =
|
||||
serde_json::from_slice(&meta_bytes).expect("failed to parse meta");
|
||||
meta["generation"] = serde_json::json!(9999);
|
||||
let tampered = serde_json::to_vec_pretty(&meta).expect("failed to serialize");
|
||||
fs::write(&meta_path, &tampered).expect("failed to write tampered meta");
|
||||
|
||||
// Read should fail — generation mismatch
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.failure();
|
||||
}
|
||||
|
||||
/// Leftover .tmp files from a crash should not interfere with normal operation.
|
||||
#[test]
|
||||
fn test_leftover_tmp_files_harmless() {
|
||||
let env = helpers::TestEnv::new();
|
||||
setup_with_cached_spec(&env);
|
||||
|
||||
let alias_dir = env.cache_dir.join("petstore");
|
||||
|
||||
// Create leftover .tmp files (simulating interrupted write)
|
||||
fs::write(alias_dir.join("raw.json.tmp"), b"partial data").expect("failed to create tmp file");
|
||||
fs::write(alias_dir.join("index.json.tmp"), b"partial index")
|
||||
.expect("failed to create tmp file");
|
||||
|
||||
// Normal operations should still work
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.success();
|
||||
}
|
||||
|
||||
/// A completely empty alias directory (no files at all) should be treated as not found.
|
||||
#[test]
|
||||
fn test_empty_alias_directory() {
|
||||
let env = helpers::TestEnv::new();
|
||||
|
||||
// Create an empty directory that looks like an alias
|
||||
let empty_alias_dir = env.cache_dir.join("ghost-alias");
|
||||
fs::create_dir_all(&empty_alias_dir).expect("failed to create dir");
|
||||
|
||||
// Should fail — no meta.json
|
||||
let result = helpers::run_cmd(&env, &["list", "ghost-alias", "--robot"]);
|
||||
result.failure();
|
||||
}
|
||||
170
tests/lock_contention_test.rs
Normal file
170
tests/lock_contention_test.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! Lock contention tests for cache write safety.
|
||||
//!
|
||||
//! Verifies that concurrent access to the same alias doesn't cause corruption
|
||||
//! or panics. Uses multiple threads (not processes) for simplicity.
|
||||
|
||||
#![allow(deprecated)] // assert_cmd::Command::cargo_bin deprecation
|
||||
|
||||
mod helpers;
|
||||
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
/// Multiple threads fetching the same alias concurrently should all succeed
|
||||
/// or receive CACHE_LOCKED errors — never corruption.
|
||||
///
|
||||
/// After all threads complete, the final cache state should be valid.
|
||||
#[test]
|
||||
fn test_concurrent_fetch_no_corruption() {
|
||||
let env = helpers::TestEnv::new();
|
||||
let fixture_path = helpers::fixture_path("petstore.json");
|
||||
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
|
||||
|
||||
// First, do an initial fetch so the alias exists
|
||||
helpers::run_cmd(&env, &["fetch", fixture_str, "--alias", "petstore"]).success();
|
||||
|
||||
let thread_count = 8;
|
||||
let barrier = Arc::new(Barrier::new(thread_count));
|
||||
let home_dir = env.home_dir.clone();
|
||||
let fixture = fixture_str.to_string();
|
||||
|
||||
let handles: Vec<_> = (0..thread_count)
|
||||
.map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let home = home_dir.clone();
|
||||
let fix = fixture.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
// Wait for all threads to be ready
|
||||
barrier.wait();
|
||||
|
||||
// All threads try to re-fetch the same alias with --force
|
||||
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||
.expect("binary not found")
|
||||
.env("SWAGGER_CLI_HOME", &home)
|
||||
.args(["fetch", &fix, "--alias", "petstore", "--force"])
|
||||
.output()
|
||||
.expect("failed to execute");
|
||||
|
||||
output.status.code().unwrap_or(-1)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
|
||||
// All should either succeed (0) or get CACHE_LOCKED (exit code 9)
|
||||
for (i, code) in exit_codes.iter().enumerate() {
|
||||
assert!(
|
||||
*code == 0 || *code == 9,
|
||||
"thread {i} exited with unexpected code {code} (expected 0 or 9)"
|
||||
);
|
||||
}
|
||||
|
||||
// At least one should succeed
|
||||
assert!(
|
||||
exit_codes.contains(&0),
|
||||
"at least one thread should succeed"
|
||||
);
|
||||
|
||||
// Final state should be valid — list should work
|
||||
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
|
||||
result.success();
|
||||
|
||||
// Doctor should report healthy
|
||||
let result = helpers::run_cmd(&env, &["doctor", "--robot"]);
|
||||
let a = result.success();
|
||||
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||
// Doctor output should indicate health
|
||||
assert!(
|
||||
json["data"]["health"].as_str().is_some(),
|
||||
"doctor should report health status"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple threads listing the same alias concurrently should all succeed.
|
||||
/// Reads are not locked (only writes acquire the lock).
|
||||
#[test]
|
||||
fn test_concurrent_reads_no_contention() {
|
||||
let env = helpers::TestEnv::new();
|
||||
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||
|
||||
let thread_count = 16;
|
||||
let barrier = Arc::new(Barrier::new(thread_count));
|
||||
let home_dir = env.home_dir.clone();
|
||||
|
||||
let handles: Vec<_> = (0..thread_count)
|
||||
.map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let home = home_dir.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
|
||||
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||
.expect("binary not found")
|
||||
.env("SWAGGER_CLI_HOME", &home)
|
||||
.args(["list", "petstore", "--robot"])
|
||||
.output()
|
||||
.expect("failed to execute");
|
||||
|
||||
output.status.code().unwrap_or(-1)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
|
||||
// ALL reads should succeed (no locking on reads)
|
||||
for (i, code) in exit_codes.iter().enumerate() {
|
||||
assert_eq!(*code, 0, "thread {i} read failed with exit code {code}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Concurrent writes to DIFFERENT aliases should never interfere with each other.
|
||||
#[test]
|
||||
fn test_concurrent_different_aliases() {
|
||||
let env = helpers::TestEnv::new();
|
||||
let fixture_path = helpers::fixture_path("petstore.json");
|
||||
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
|
||||
|
||||
let aliases = ["alpha", "bravo", "charlie", "delta"];
|
||||
let barrier = Arc::new(Barrier::new(aliases.len()));
|
||||
let home_dir = env.home_dir.clone();
|
||||
let fixture = fixture_str.to_string();
|
||||
|
||||
let handles: Vec<_> = aliases
|
||||
.iter()
|
||||
.map(|alias| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let home = home_dir.clone();
|
||||
let fix = fixture.clone();
|
||||
let a = alias.to_string();
|
||||
|
||||
thread::spawn(move || {
|
||||
barrier.wait();
|
||||
|
||||
let output = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||
.expect("binary not found")
|
||||
.env("SWAGGER_CLI_HOME", &home)
|
||||
.args(["fetch", &fix, "--alias", &a])
|
||||
.output()
|
||||
.expect("failed to execute");
|
||||
|
||||
(a, output.status.code().unwrap_or(-1))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results: Vec<(String, i32)> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
|
||||
// All should succeed — different aliases means different lock files
|
||||
for (alias, code) in &results {
|
||||
assert_eq!(*code, 0, "fetch to alias '{alias}' failed with code {code}");
|
||||
}
|
||||
|
||||
// All aliases should be listable
|
||||
for alias in &aliases {
|
||||
helpers::run_cmd(&env, &["list", alias, "--robot"]).success();
|
||||
}
|
||||
}
|
||||
133
tests/property_test.rs
Normal file
133
tests/property_test.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user