Files
IronStorage/crates/storage/tests/mobile_watch.rs

278 lines
9.4 KiB
Rust

#![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<dyn std::error::Error>>;
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::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(), 4);
assert_eq!(
receiver.apply(SecretBytes::new(changed_watch.snapshot().expose().to_vec()))?,
WatchSnapshotApply::PairingChanged
);
assert_eq!(receiver.current().expect("new pairing").revision(), 4);
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!(fresh_watch.current().is_none());
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::Replaced
);
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 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::Stale
);
assert!(runtime.apply_snapshot(vec![0; 64]).is_err());
assert_eq!(
runtime.presentation_at(59)?.state(),
WatchPresentationState::Error
);
Ok(())
}