#![forbid(unsafe_code)] use std::fs; use ironstorage::{ mobile_watch::{ MobileWatchSnapshotState, WatchPersistenceAction, WatchPresentationState, WatchRuntime, WatchSnapshotApply, WatchSnapshotEntry, WatchSnapshotReceiver, WatchSnapshotSender, }, otp::OtpAlgorithm, repository::{EntryPath, SecretBytes}, }; type TestResult = Result<(), Box>; fn entry(path: &str, issuer: &str, account: &str, secret: &[u8]) -> WatchSnapshotEntry { WatchSnapshotEntry::new( EntryPath::parse(path).expect("fixture path").to_string(), Some(issuer.to_owned()), account.to_owned(), OtpAlgorithm::Sha256, 8, 30, SecretBytes::new(secret.to_vec()), ) } #[test] fn replacement_snapshots_reject_replays_conflicts_and_pairing_changes() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let mut sender = WatchSnapshotSender::load(journal.clone()); let first = sender.prepare( "paired-watch-a", vec![entry("otp/alice", "Acme", "alice", b"first-secret")], )?; assert_eq!(first.revision(), 1); assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending); let mut receiver = WatchSnapshotReceiver::default(); assert_eq!( receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); assert_eq!(receiver.current().expect("snapshot").entries().len(), 1); assert_eq!( sender.acknowledge(first.delivered_receipt())?.state(), MobileWatchSnapshotState::Delivered ); assert_eq!( sender .acknowledge(&receiver.current_receipt().expect("accepted receipt"))? .state(), MobileWatchSnapshotState::Current ); let duplicate = sender.prepare( "paired-watch-a", vec![entry("otp/alice", "Acme", "alice", b"first-secret")], )?; assert_eq!(duplicate.revision(), 1); assert_eq!(duplicate.snapshot().expose(), first.snapshot().expose()); assert_eq!( receiver.apply(SecretBytes::new(duplicate.snapshot().expose().to_vec()))?, WatchSnapshotApply::Duplicate ); let replacement = sender.prepare( "paired-watch-a", vec![entry("otp/bob", "Acme", "bob", b"second-secret")], )?; assert_eq!(replacement.revision(), 2); assert_eq!( receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); assert_eq!( receiver.current().expect("replacement").entries()[0].account(), "bob" ); assert_eq!( receiver.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( receiver.current().expect("stale ignored").entries()[0].account(), "bob" ); let revoked = sender.prepare("paired-watch-a", Vec::new())?; assert_eq!(revoked.revision(), 3); assert_eq!( receiver.apply(SecretBytes::new(revoked.snapshot().expose().to_vec()))?, WatchSnapshotApply::Revoked ); assert!( receiver .current() .expect("revocation marker") .is_revocation() ); assert_eq!( receiver.apply(SecretBytes::new(replacement.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); let reset_journal = directory.path().join("reset-watch-snapshot.toml"); let mut reset_sender = WatchSnapshotSender::load(reset_journal); let reset_revocation = reset_sender.prepare("paired-watch-a", Vec::new())?; assert_eq!(reset_revocation.revision(), 1); assert_eq!( receiver.apply(SecretBytes::new( reset_revocation.snapshot().expose().to_vec() ))?, WatchSnapshotApply::Stale ); assert_eq!( receiver.replace_authoritative(SecretBytes::new( reset_revocation.snapshot().expose().to_vec() ))?, WatchSnapshotApply::Revoked ); assert!( receiver .current() .expect("reset revocation") .is_revocation() ); let after_reset = reset_sender.prepare( "paired-watch-a", vec![entry("otp/reset", "Acme", "reset", b"reset-secret")], )?; assert_eq!(after_reset.revision(), 2); assert_eq!( receiver.apply(SecretBytes::new(after_reset.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); let changed_watch = sender.prepare( "paired-watch-b", vec![entry("otp/carol", "Acme", "carol", b"third-secret")], )?; assert_eq!(changed_watch.revision(), 1); assert_eq!( receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( receiver .replace_authoritative(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::PairingChanged ); assert_eq!(receiver.current().expect("new pairing").revision(), 1); let mut fresh_watch = WatchSnapshotReceiver::default(); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::Replaced ); let mut damaged = changed_watch.snapshot().expose().to_vec(); damaged[20] ^= 0x55; assert!(fresh_watch.apply(SecretBytes::new(damaged)).is_err()); assert_eq!( fresh_watch .current() .expect("last valid snapshot") .entries()[0] .account(), "carol" ); assert_eq!( fresh_watch.apply(SecretBytes::new(first.snapshot().expose().to_vec()))?, WatchSnapshotApply::Stale ); assert_eq!( fresh_watch.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?, WatchSnapshotApply::Duplicate ); let persisted = fs::read_to_string(journal)?; for forbidden in [ "first-secret", "second-secret", "third-secret", "reset-secret", "otpauth://", "94287082", ] { assert!(!persisted.contains(forbidden), "journal leaked {forbidden}"); } Ok(()) } #[test] fn journal_keeps_revisions_monotonic_across_sender_reloads() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let first = WatchSnapshotSender::load(journal.clone()).prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"secret")], )?; assert_eq!(first.revision(), 1); let same = WatchSnapshotSender::load(journal.clone()).prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"secret")], )?; assert_eq!(same.revision(), 1); let changed = WatchSnapshotSender::load(journal).prepare("paired-watch", Vec::new())?; assert_eq!(changed.revision(), 2); Ok(()) } #[test] fn journal_upgrade_starts_a_new_generation_at_revision_one() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")]; WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?; let legacy = fs::read_to_string(&journal)? .lines() .filter(|line| !line.starts_with("generation = ")) .collect::>() .join("\n") .replace("revision = 1", "revision = 42"); fs::write(&journal, legacy)?; let reset = WatchSnapshotSender::load(journal.clone()).prepare("paired-watch", entries())?; assert_eq!(reset.revision(), 1); let reloaded = WatchSnapshotSender::load(journal).prepare("paired-watch", entries())?; assert_eq!(reloaded.revision(), 1); assert_eq!(reset.snapshot().expose(), reloaded.snapshot().expose()); Ok(()) } #[test] fn duplicate_delivery_after_watch_restart_recovers_a_lost_acknowledgement() -> TestResult { let directory = tempfile::tempdir()?; let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml")); let transfer = sender.prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"secret")], )?; let persisted_snapshot = transfer.snapshot().expose().to_vec(); let mut interrupted_runtime = WatchRuntime::default(); let applied = interrupted_runtime.apply_snapshot(persisted_snapshot.clone())?; assert_eq!(applied.persistence(), WatchPersistenceAction::Replace); assert_eq!(sender.status().state(), MobileWatchSnapshotState::Pending); let mut restarted_runtime = WatchRuntime::default(); restarted_runtime.apply_snapshot(persisted_snapshot.clone())?; let redelivered = restarted_runtime.apply_snapshot(persisted_snapshot)?; assert_eq!(redelivered.apply(), WatchSnapshotApply::Duplicate); assert_eq!(redelivered.persistence(), WatchPersistenceAction::Keep); assert_eq!( sender.acknowledge(redelivered.receipt())?.state(), MobileWatchSnapshotState::Current ); Ok(()) } #[test] fn confirmed_unchanged_snapshot_stays_current_when_republished() -> TestResult { let directory = tempfile::tempdir()?; let journal = directory.path().join("watch-snapshot.toml"); let entries = || vec![entry("otp/alice", "Acme", "alice", b"secret")]; let mut sender = WatchSnapshotSender::load(journal.clone()); let first = sender.prepare("paired-watch", entries())?; let receipt = WatchRuntime::default() .apply_snapshot(first.snapshot().expose().to_vec())? .receipt() .to_vec(); assert_eq!( sender.acknowledge(&receipt)?.state(), MobileWatchSnapshotState::Current ); assert_eq!(sender.prepare("paired-watch", entries())?.revision(), 1); assert_eq!(sender.status().state(), MobileWatchSnapshotState::Current); let mut reloaded = WatchSnapshotSender::load(journal); assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current); reloaded.prepare("paired-watch", entries())?; assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Current); assert_eq!(reloaded.prepare("paired-watch", Vec::new())?.revision(), 2); assert_eq!(reloaded.status().state(), MobileWatchSnapshotState::Pending); Ok(()) } #[test] fn authoritative_context_switches_sender_generation_and_rejects_delayed_packets() -> TestResult { let directory = tempfile::tempdir()?; let mut old_sender = WatchSnapshotSender::load(directory.path().join("old.toml")); old_sender.prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice", b"old-secret")], )?; for revision in 2..5 { old_sender.prepare( "paired-watch", vec![entry( "otp/alice", "Acme", &format!("alice-{revision}"), b"old-secret", )], )?; } let old = old_sender.prepare( "paired-watch", vec![entry("otp/alice", "Acme", "alice-5", b"old-secret")], )?; assert_eq!(old.revision(), 5); let mut new_sender = WatchSnapshotSender::load(directory.path().join("new.toml")); let new = new_sender.prepare( "paired-watch", vec![entry("otp/bob", "Acme", "bob", b"new-secret")], )?; assert_eq!(new.revision(), 1); let mut runtime = WatchRuntime::default(); runtime.apply_snapshot(old.snapshot().expose().to_vec())?; let queued_new = runtime.apply_snapshot(new.snapshot().expose().to_vec())?; assert_eq!(queued_new.apply(), WatchSnapshotApply::Stale); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Ready ); assert_eq!(runtime.records_at(59)?[0].account(), "alice-5"); let replaced = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?; assert_eq!(replaced.apply(), WatchSnapshotApply::PairingChanged); assert_eq!(replaced.persistence(), WatchPersistenceAction::Replace); assert_eq!(runtime.records_at(59)?[0].account(), "bob"); assert_eq!( new_sender.acknowledge(replaced.receipt())?.state(), MobileWatchSnapshotState::Current ); let delayed_old = runtime.apply_snapshot(old.snapshot().expose().to_vec())?; assert_eq!(delayed_old.apply(), WatchSnapshotApply::Stale); assert_eq!(runtime.records_at(59)?[0].account(), "bob"); let newer = new_sender.prepare( "paired-watch", vec![entry("otp/carol", "Acme", "carol", b"newer-secret")], )?; runtime.apply_authoritative_snapshot(newer.snapshot().expose().to_vec())?; let delayed_context = runtime.apply_authoritative_snapshot(new.snapshot().expose().to_vec())?; assert_eq!(delayed_context.apply(), WatchSnapshotApply::Stale); assert_eq!(runtime.records_at(59)?[0].account(), "carol"); assert!(runtime.apply_snapshot(vec![0; 64]).is_err()); assert_eq!(runtime.records_at(59)?[0].account(), "carol"); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Stale ); runtime.sync_unavailable(); assert_eq!(runtime.records_at(59)?[0].account(), "carol"); Ok(()) } #[test] fn watch_runtime_generates_view_ready_totp_and_clears_secrets_when_locked() -> TestResult { let directory = tempfile::tempdir()?; let mut sender = WatchSnapshotSender::load(directory.path().join("watch-snapshot.toml")); let snapshot = sender.prepare( "paired-watch", vec![WatchSnapshotEntry::new( "otp/alice".to_owned(), Some("Acme".to_owned()), "alice".to_owned(), OtpAlgorithm::Sha1, 8, 30, SecretBytes::new(b"12345678901234567890".to_vec()), )], )?; let mut runtime = WatchRuntime::default(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Syncing ); runtime.sync_finished(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Empty ); runtime.sync_started(); runtime.sync_unavailable(); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Unavailable ); runtime.sync_started(); let update = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!(update.apply(), WatchSnapshotApply::Replaced); assert_eq!(update.persistence(), WatchPersistenceAction::Replace); assert_eq!(update.selected_entries(), 1); assert!(!update.receipt().is_empty()); let records = runtime.records_at(59)?; assert_eq!(records.len(), 1); assert_eq!(records[0].path(), "otp/alice"); assert_eq!(records[0].code().expose(), b"94287082"); assert_eq!(records[0].valid_until(), 60); assert_eq!(records[0].remaining_at(59), 1); let presentation = runtime.presentation_at(59)?; assert_eq!(presentation.state(), WatchPresentationState::Ready); assert_eq!(presentation.records()[0].remaining(), 1); runtime.protected_data_unavailable(); assert!(runtime.records_at(59).is_err()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Locked ); let restored = runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!(restored.persistence(), WatchPersistenceAction::Replace); assert_eq!(runtime.records_at(59)?[0].code().expose(), b"94287082"); let revocation = sender.prepare("paired-watch", Vec::new())?; let revoked = runtime.apply_snapshot(revocation.snapshot().expose().to_vec())?; assert_eq!(revoked.apply(), WatchSnapshotApply::Revoked); assert_eq!(revoked.persistence(), WatchPersistenceAction::Delete); assert!(runtime.records_at(59)?.is_empty()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Empty ); runtime.apply_snapshot(snapshot.snapshot().expose().to_vec())?; assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Empty ); assert!(runtime.apply_snapshot(vec![0; 64]).is_err()); assert_eq!( runtime.presentation_at(59)?.state(), WatchPresentationState::Stale ); Ok(()) }