#![cfg(feature = "test-util")]
use std::time::{Duration, Instant};
mod common;
use common::*;
use osdns::testing::{
CrashOutcome, DebugReconcile, FakeDns, FaultInjector, TxPoint, manager_for_testing_with_policy,
};
use osdns::{ConflictPolicy, RecoveryOutcome};
fn enforce_manager(tag: &str) -> Fixture {
let dir = temp_dir(tag);
let fake = FakeDns::new();
let manager = manager_for_testing_with_policy(
"io.osdns.test",
&dir,
&fake,
Duration::from_secs(30),
ConflictPolicy::Enforce,
)
.unwrap();
Fixture { manager, fake, dir }
}
fn wait_until(predicate: impl Fn() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if predicate() {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("condition not reached within 5s");
}
#[test]
fn enforce_rebases_and_reapplies_on_external_change() {
let fixture = enforce_manager("enforce-rebase");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
let _watch = fixture.manager.watch(std::sync::Arc::new(|_| {})).unwrap();
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
wait_until(|| fixture.fake.current_state(IFACE1).unwrap() == Some(state_with("1.1.1.1")));
lease.restore().unwrap();
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9")),
"restore must return to the rebased external base, not the pre-lease state"
);
assert!(journal_files(&fixture.dir).is_empty());
}
#[test]
fn cooperative_still_reports_conflicts_without_reconciliation() {
let fixture = new_fixture("enforce-coop");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
let _watch = fixture.manager.watch(std::sync::Arc::new(|_| {})).unwrap();
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
std::thread::sleep(Duration::from_millis(700));
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9")),
"cooperative must never reapply"
);
let failure = lease.restore().unwrap_err();
assert!(failure.error.is_external_modification());
}
#[test]
fn reconciled_lease_update_and_still_ours_are_stable() {
let fixture = enforce_manager("enforce-still-ours");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
let _watch = fixture.manager.watch(std::sync::Arc::new(|_| {})).unwrap();
lease.update(&iface_config(1, "8.8.8.8")).unwrap();
fixture
.fake
.external_change(IFACE1, state_with("8.8.8.8"))
.unwrap();
std::thread::sleep(Duration::from_millis(700));
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("8.8.8.8")),
"an event matching the applied state must not trigger a reapply loop"
);
lease.restore().unwrap();
}
#[test]
fn enforce_works_without_user_watch() {
let fixture = enforce_manager("enforce-nowatch");
assert!(!fixture.manager.debug_enforce_watching());
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
assert!(fixture.manager.debug_enforce_watching());
assert_eq!(fixture.manager.debug_enforce_refs(), 1);
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
wait_until(|| fixture.fake.current_state(IFACE1).unwrap() == Some(state_with("1.1.1.1")));
lease.restore().unwrap();
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9")),
"restore must return to the rebased external base"
);
assert!(!fixture.manager.debug_enforce_watching());
assert_eq!(fixture.manager.debug_enforce_refs(), 0);
}
#[test]
fn public_watch_is_optional_and_does_not_own_enforce() {
use std::sync::{Arc, Mutex};
let fixture = enforce_manager("enforce-watch-optional");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
assert!(fixture.manager.debug_enforce_watching());
let seen: Arc<Mutex<Vec<osdns::DnsEvent>>> = Arc::new(Mutex::new(Vec::new()));
let seen_clone = Arc::clone(&seen);
let watch = fixture
.manager
.watch(Arc::new(move |event| {
seen_clone.lock().unwrap().push(event.clone());
}))
.unwrap();
std::thread::sleep(Duration::from_millis(600));
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
wait_until(|| fixture.fake.current_state(IFACE1).unwrap() == Some(state_with("1.1.1.1")));
wait_until(|| !seen.lock().unwrap().is_empty());
watch.stop();
assert!(
fixture.manager.debug_enforce_watching(),
"Enforce must survive public watcher teardown while a lease is active"
);
fixture
.fake
.external_change(IFACE1, state_with("8.8.8.8"))
.unwrap();
wait_until(|| fixture.fake.current_state(IFACE1).unwrap() == Some(state_with("1.1.1.1")));
lease.restore().unwrap();
assert!(!fixture.manager.debug_enforce_watching());
}
#[test]
fn enforce_fails_honestly_without_watch_capability() {
use osdns::{BackendKind, Capabilities};
let dir = temp_dir("enforce-no-watch-cap");
let caps = Capabilities::new(BackendKind::Fake)
.with_read(true)
.with_global_dns(true)
.with_per_interface_dns(true)
.with_search_domains(true)
.with_split_dns(true)
.with_default_route(true)
.with_watch(false)
.with_cache_flush(true);
let fake = FakeDns::with_capabilities(caps);
let result = manager_for_testing_with_policy(
"io.osdns.test",
&dir,
&fake,
Duration::from_secs(30),
ConflictPolicy::Enforce,
);
assert!(
matches!(result, Err(osdns::Error::Unsupported { .. })),
"Enforce construction without watch support must fail, not silently downgrade"
);
}
#[test]
fn rebase_is_transactional_across_crash() {
let fixture = enforce_manager("enforce-crash");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
fixture.manager.suspend_enforce_background();
let injector = FaultInjector::new();
injector.crash_at(TxPoint::AfterApply);
fixture.manager.install_fault_injector(injector.clone());
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
let outcome =
osdns::testing::catch_crash(|| fixture.manager.debug_reconcile("fake:interface:1"));
injector.clear();
assert!(matches!(outcome, CrashOutcome::Crashed));
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("1.1.1.1")),
"the uncommitted overlay is on the OS"
);
let record = journal_record_json(&fixture.dir);
assert_eq!(record["phase"], "Prepared");
assert_eq!(
record["before"]["data"]["Configured"]["nameservers"][0], "9.9.9.9",
"the Prepared record must carry the external base, not the old one"
);
drop(lease);
let outcomes = fixture.manager.recover_stale().unwrap();
assert_eq!(outcomes.len(), 1, "{outcomes:?}");
assert!(matches!(&outcomes[0], RecoveryOutcome::Restored { .. }));
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9")),
"recovery must restore the external base, never overwrite it"
);
assert!(journal_files(&fixture.dir).is_empty());
}
#[test]
fn rebase_journal_write_failure_defers_and_preserves_external_state() {
let fixture = enforce_manager("enforce-journal-fail");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
fixture.manager.suspend_enforce_background();
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
fixture.manager.set_journal_fail_writes(true);
let outcome = fixture.manager.debug_reconcile("fake:interface:1").unwrap();
assert_eq!(outcome, DebugReconcile::Deferred);
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9")),
"a journal failure must leave the external state untouched"
);
let record = journal_record_json(&fixture.dir);
assert_eq!(record["phase"], "Applied");
assert_eq!(
record["applied"]["data"]["Configured"]["nameservers"][0],
"1.1.1.1"
);
fixture.manager.set_journal_fail_writes(false);
let outcome = fixture.manager.debug_reconcile("fake:interface:1").unwrap();
assert_eq!(outcome, DebugReconcile::Rebased);
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("1.1.1.1"))
);
let record = journal_record_json(&fixture.dir);
assert_eq!(
record["before"]["data"]["Configured"]["nameservers"][0],
"9.9.9.9"
);
lease.restore().unwrap();
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9"))
);
}
#[rstest::rstest]
#[case(false)]
#[case(true)]
fn failed_rebase_rollback_preserves_external_base(#[case] finalize_live: bool) {
use osdns::testing::FakeOp;
let fixture = enforce_manager("enforce-rollback-fail");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
fixture.manager.suspend_enforce_background();
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
fixture
.fake
.inject_backend_failure_after(FakeOp::Readback, 2, 1, "verification read failed");
fixture
.fake
.inject_backend_failure(FakeOp::Restore, 1, "rollback failed");
assert_eq!(
fixture.manager.debug_reconcile(IFACE1).unwrap(),
DebugReconcile::Deferred
);
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("1.1.1.1"))
);
let record = journal_record_json(&fixture.dir);
assert_eq!(record["phase"], "Prepared");
assert_eq!(
record["before"]["data"]["Configured"]["nameservers"][0],
"9.9.9.9"
);
if finalize_live {
assert_eq!(
fixture.manager.debug_reconcile(IFACE1).unwrap(),
DebugReconcile::StillOurs
);
lease.restore().unwrap();
} else {
drop(lease);
drop(fixture.manager);
let recovered = manager_for_testing(
"io.osdns.test",
&fixture.dir,
&fixture.fake,
Duration::from_secs(30),
)
.unwrap();
let outcomes = recovered.recover_stale().unwrap();
assert!(
matches!(&outcomes[..], [RecoveryOutcome::Restored { .. }]),
"{outcomes:?}"
);
}
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("9.9.9.9"))
);
assert!(journal_files(&fixture.dir).is_empty());
}
#[test]
fn events_during_defer_windows_are_pending_never_dropped() {
let fixture = enforce_manager("enforce-defer");
let lease = fixture.manager.apply(&iface_config(1, "1.1.1.1")).unwrap();
fixture.manager.suspend_enforce_background();
assert!(fixture.fake.external_remove(IFACE1).unwrap());
let outcome = fixture.manager.debug_reconcile("fake:interface:1").unwrap();
assert_eq!(outcome, DebugReconcile::Deferred);
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
let outcome = fixture.manager.debug_reconcile("fake:interface:1").unwrap();
assert_eq!(outcome, DebugReconcile::Rebased);
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("1.1.1.1"))
);
fixture
.fake
.external_change(IFACE1, state_with("9.9.9.9"))
.unwrap();
assert_eq!(
fixture.manager.debug_reconcile("fake:interface:1").unwrap(),
DebugReconcile::Rebased
);
fixture
.fake
.external_change(IFACE1, state_with("8.8.8.8"))
.unwrap();
assert_eq!(
fixture.manager.debug_reconcile("fake:interface:1").unwrap(),
DebugReconcile::Rebased
);
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("1.1.1.1"))
);
let record = journal_record_json(&fixture.dir);
assert_eq!(
record["before"]["data"]["Configured"]["nameservers"][0], "8.8.8.8",
"the rebased journal base must track the latest external state"
);
lease.restore().unwrap();
assert_eq!(
fixture.fake.current_state(IFACE1).unwrap(),
Some(state_with("8.8.8.8"))
);
}