nigig-org/crates/apps/nigig-traffic/tests/persistence_faults.rs

146 lines
4.6 KiB
Rust

//! Persistence fault-injection gates (TRAFFIC-11).
//!
//! Serialize/write/flush/sync/rename faults must preserve the previous
//! profile and surface failure. Corrupt/future data enters recovery or
//! read-only state, never silent empty defaults. Learner data is anonymous
//! by default and bounded (8 MiB, 10k attempts).
use nigig_traffic::traffic::persistence::{
save_atomic, AttemptRecord, LearnerProfile, LoadOutcome, MAX_PROFILE_BYTES,
};
use std::path::PathBuf;
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("nigig-traffic-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn attempt(id: &str, score: i32, digest: u64) -> AttemptRecord {
AttemptRecord {
scenario_id: id.into(),
content_version: 0,
ticks: 600,
mistakes: 1,
score,
max_points: 100,
passed: true,
replay_digest: digest,
}
}
#[test]
fn save_then_faulty_overwrite_preserves_previous_profile() {
let d = dir("faults");
let mut p = LearnerProfile::new("anon-f".into());
p.record(attempt("lane_straight", 90, 7));
save_atomic(&d, &p).expect("initial save");
// Fault: clobber the file with garbage (simulates a torn write that
// fsync/rename did not protect). Load must report Corrupt with bytes
// preserved — never an empty profile that looks like a fresh learner.
let profile_path = d.join("learner_profile_v1.json");
std::fs::write(&profile_path, b"\xff\xfe torn write").expect("clobber");
assert_eq!(
nigig_traffic::traffic::persistence::load(&d),
LoadOutcome::Corrupt {
bytes_preserved: true
}
);
assert!(profile_path.exists(), "corrupt bytes must be preserved");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn future_schema_is_read_only_not_default() {
let d = dir("future");
std::fs::create_dir_all(&d).expect("mkdir");
std::fs::write(
d.join("learner_profile_v1.json"),
b"{\"schema\":99,\"learner_id\":\"a\",\"attempts\":[]}",
)
.expect("write");
assert_eq!(
nigig_traffic::traffic::persistence::load(&d),
LoadOutcome::FutureVersion { found: 99 }
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn oversize_profile_is_rejected_before_alloc() {
let d = dir("oversize");
std::fs::create_dir_all(&d).expect("mkdir");
// One byte over the bound, otherwise well-formed.
let mut bytes = vec![b' '; (MAX_PROFILE_BYTES + 1) as usize];
bytes[0] = b'{';
std::fs::write(d.join("learner_profile_v1.json"), &bytes).expect("write");
assert_eq!(
nigig_traffic::traffic::persistence::load(&d),
LoadOutcome::Corrupt {
bytes_preserved: true
}
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn malformed_attempts_fail_closed() {
let d = dir("malformed");
std::fs::create_dir_all(&d).expect("mkdir");
std::fs::write(
d.join("learner_profile_v1.json"),
b"{\"schema\":1,\"learner_id\":\"a\",\"attempts\":[{\"id\":\"x\"}]}",
)
.expect("write");
assert_eq!(
nigig_traffic::traffic::persistence::load(&d),
LoadOutcome::Corrupt {
bytes_preserved: true
}
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn traversal_ids_rejected() {
let d = dir("traversal");
std::fs::create_dir_all(&d).expect("mkdir");
std::fs::write(
d.join("learner_profile_v1.json"),
b"{\"schema\":1,\"learner_id\":\"../evil\",\"attempts\":[]}",
)
.expect("write");
assert_eq!(
nigig_traffic::traffic::persistence::load(&d),
LoadOutcome::Corrupt {
bytes_preserved: true
}
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn compaction_bounds_attempts_at_10k() {
let mut p = LearnerProfile::new("anon-c".into());
for i in 0..10_005 {
p.record(attempt("s", 10, i));
}
assert!(p.attempts.len() <= 10_000, "len {}", p.attempts.len());
}
#[test]
fn learners_are_isolated_by_profile() {
let da = dir("iso-a");
let db = dir("iso-b");
let mut pa = LearnerProfile::new("anon-a".into());
pa.record(attempt("lane_straight", 100, 1));
let pb = LearnerProfile::new("anon-b".into());
save_atomic(&da, &pa).expect("save a");
save_atomic(&db, &pb).expect("save b");
match nigig_traffic::traffic::persistence::load(&db) {
LoadOutcome::Loaded(got) => assert_eq!(got.best("lane_straight"), None),
other => panic!("unexpected {other:?}"),
}
let _ = std::fs::remove_dir_all(&da);
let _ = std::fs::remove_dir_all(&db);
}