Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470use std::fs;
use tempfile::TempDir;use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{ AppPaths, DEFAULT_HISTORY_REVISIONS, FileFingerprint, StateStore, gc_interval, prune_manifest_history, validate_history_revision_limit,};
#[test]fn caches_blob_hashes_only_for_matching_file_metadata() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); let file_path = folder_path.join("note.txt"); fs::create_dir(&folder_path)?; fs::write(&file_path, "first version")?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let fingerprint = FileFingerprint::from_metadata(&fs::metadata(&file_path)?)?;
assert_eq!( store.cached_blob_hash(folder.id, "note.txt", &fingerprint)?, None ); store.save_file_fingerprint(folder.id, "note.txt", &fingerprint, "blob-hash")?; assert_eq!( store.cached_blob_hash(folder.id, "note.txt", &fingerprint)?, Some("blob-hash".to_owned()) );
fs::write(&file_path, "a longer second version")?; let changed_fingerprint = FileFingerprint::from_metadata(&fs::metadata(&file_path)?)?; assert_eq!( store.cached_blob_hash(folder.id, "note.txt", &changed_fingerprint)?, None ); Ok(())}
#[test]fn removes_fingerprints_for_files_that_are_no_longer_present() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); let file_path = folder_path.join("note.txt"); fs::create_dir(&folder_path)?; fs::write(&file_path, "contents")?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let fingerprint = FileFingerprint::from_metadata(&fs::metadata(&file_path)?)?; store.save_file_fingerprint(folder.id, "note.txt", &fingerprint, "blob-hash")?;
store.remove_missing_file_fingerprints(folder.id, &Default::default())?;
assert_eq!( store.cached_blob_hash(folder.id, "note.txt", &fingerprint)?, None ); Ok(())}
#[test]fn forget_folder_removes_all_folder_scoped_state() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; store.connection.execute( "INSERT INTO folder_rosters (folder_id, epoch, roster) VALUES (?1, 0, '{}')", rusqlite::params![folder.id.to_string()], )?;
store.forget_folder(&folder)?;
let remaining_rosters: i64 = store.connection.query_row( "SELECT COUNT(*) FROM folder_rosters WHERE folder_id = ?1", rusqlite::params![folder.id.to_string()], |row| row.get(0), )?; assert_eq!(remaining_rosters, 0); Ok(())}
#[test]fn increments_version_counters_in_sqlite() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let device_id = "device".to_owned();
assert_eq!(store.next_counter(folder.id, &device_id)?, 1); assert_eq!(store.next_counter(folder.id, &device_id)?, 2); Ok(())}
#[test]fn joining_a_new_folder_at_an_existing_path_removes_old_state() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let old_folder = store.register_folder(&folder_path)?; let invite = test_invite(uuid::Uuid::new_v4())?;
let joined_folder = store.import_invite(&folder_path, &invite)?;
assert_eq!(joined_folder.id, invite.folder_id); let old_manifest_count: i64 = store.connection.query_row( "SELECT COUNT(*) FROM manifests WHERE folder_id = ?1", rusqlite::params![old_folder.id.to_string()], |row| row.get(0), )?; assert_eq!(old_manifest_count, 0); Ok(())}
#[test]fn reconstructs_manifest_history_from_checkpoints_and_deltas() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let mut first = store.load_manifest(folder.id)?; first .entries .insert("one.txt".to_owned(), test_file_entry("one.txt")); store.save_manifest(&first)?; let mut second = first.clone(); second .entries .insert("two.txt".to_owned(), test_file_entry("two.txt")); store.save_manifest(&second)?;
let history = store.manifest_history(folder.id)?; assert_eq!( history.first().map(|revision| &revision.manifest), Some(&second) ); assert_eq!( store.manifest_revision(folder.id, history[1].revision)?, first ); let record_kinds = store .connection .prepare("SELECT kind FROM manifest_history_compact ORDER BY revision")? .query_map([], |row| row.get::<_, String>(0))? .collect::<Result<Vec<_>, _>>()?; assert_eq!(record_kinds, vec!["checkpoint", "delta", "delta"]); Ok(())}
#[test]fn preserves_reconstructible_history_across_retention_boundaries() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let mut store = StateStore::open(&paths)?; store.history_limit = 2; let folder = store.register_folder(&folder_path)?; let mut manifest = store.load_manifest(folder.id)?;
for path in ["one.txt", "two.txt", "three.txt", "four.txt"] { manifest .entries .insert(path.to_owned(), test_file_entry(path)); store.save_manifest(&manifest)?; }
let history = store.manifest_history(folder.id)?; assert_eq!(history.len(), 2); assert_eq!(history[0].manifest, manifest); assert_eq!(history[1].manifest.entries.len(), 3); let oldest_kind: String = store.connection.query_row( "SELECT kind FROM manifest_history_compact WHERE folder_id = ?1 ORDER BY revision LIMIT 1", rusqlite::params![folder.id.to_string()], |row| row.get(0), )?; assert_eq!(oldest_kind, "checkpoint"); Ok(())}
#[test]fn saves_and_clears_pending_materialization() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let mut resulting_manifest = store.load_manifest(folder.id)?; let entries = vec![test_file_entry("note.txt"), test_file_entry("todo.txt")]; resulting_manifest.entries.extend( entries .iter() .cloned() .map(|entry| (entry.path.clone(), entry)), );
store.save_pending_materialization(folder.id, &entries, &resulting_manifest)?; let pending = store .pending_materialization(folder.id)? .expect("journal exists"); assert_eq!(pending.entries, entries); assert_eq!(pending.resulting_manifest, resulting_manifest); assert!(!pending.is_legacy_single_entry);
store.connection.execute( "UPDATE pending_materializations SET entry = ?1 WHERE folder_id = ?2", rusqlite::params![serde_json::to_string(&entries[0])?, folder.id.to_string(),], )?; let pending = store .pending_materialization(folder.id)? .expect("legacy journal exists"); assert_eq!(pending.entries, vec![entries[0].clone()]); assert!(pending.is_legacy_single_entry);
store.clear_pending_materialization(folder.id)?; assert!(store.pending_materialization(folder.id)?.is_none()); Ok(())}
fn test_file_entry(path: &str) -> crate::domain::Entry { crate::domain::Entry { path: path.to_owned(), kind: crate::domain::EntryKind::File, blob_hash: Some(path.to_owned()), size_bytes: Some(1), modified_at: OffsetDateTime::now_utc(), clock: Default::default(), author_device_id: "device".to_owned(), }}
fn test_invite(folder_id: uuid::Uuid) -> anyhow::Result<crate::protocol::Invite> { let owner = iroh::SecretKey::generate(); let endpoint = iroh::EndpointAddr::new(owner.public()); let capability = "folder-capability".to_owned(); let mut roster = crate::domain::FolderRoster::create( folder_id, capability.clone(), crate::domain::RosterMember { device_id: endpoint.id.to_string(), display_name: None, role: crate::domain::MemberRole::Owner, }, )?; let roster = roster.sign(&owner)?; let mut invite = crate::protocol::Invite { protocol_version: crate::protocol::INVITATION_PROTOCOL_VERSION, folder_id, folder_name: "folder".to_owned(), inviter_device_id: endpoint.id.to_string(), inviter_endpoint: endpoint, capability, roster, expires_at: OffsetDateTime::now_utc() + time::Duration::hours(1), signature: None, }; invite.sign(&owner)}
#[test]fn rotating_a_capability_retains_its_previous_value() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?; let owner = iroh::SecretKey::generate(); let owner_id = owner.public().to_string(); let mut roster = crate::domain::FolderRoster::create( folder.id, folder.capability.clone(), crate::domain::RosterMember { device_id: owner_id.clone(), display_name: None, role: crate::domain::MemberRole::Owner, }, )?; let roster = roster .rotate_capability(store.new_capability()) .sign(&owner)?; store.replace_capability_and_roster(folder.id, &roster)?;
assert_ne!(roster.capability, folder.capability); Ok(())}
#[test]fn records_and_clears_sync_errors() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?;
store.record_sync_failure(folder.id, &anyhow::anyhow!("peer offline"))?; assert_eq!( store.last_sync_error(folder.id)?, Some("peer offline".to_owned()) ); store.record_successful_sync(folder.id)?;
assert_eq!(store.last_sync_error(folder.id)?, None); assert!(store.last_successful_sync(folder.id)?.is_some()); Ok(())}
#[test]fn exports_and_restores_an_identity_backup() -> anyhow::Result<()> { let directory = TempDir::new()?; let source = AppPaths::from_data_directory(directory.path().join("source"))?; let target = AppPaths::from_data_directory(directory.path().join("target"))?; let backup = directory.path().join("identity.appa"); let source_identity = source.load_identity()?; let target_identity = target.load_identity()?; assert_ne!(source_identity.public(), target_identity.public());
source.export_identity(&backup)?; target.import_identity(&backup, true)?;
assert_eq!(source_identity.public(), target.load_identity()?.public()); assert!(source.export_identity(&backup).is_err()); Ok(())}
#[test]fn prunes_manifest_history_to_its_configured_limit() -> anyhow::Result<()> { let directory = TempDir::new()?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder_id = uuid::Uuid::new_v4(); for _ in 0..3 { store.connection.execute( "INSERT INTO manifest_history_compact (folder_id, saved_at, kind, payload) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![folder_id.to_string(), OffsetDateTime::now_utc().format(&Rfc3339)?, "checkpoint", vec![0_u8]], )?; } let transaction = store.connection.unchecked_transaction()?; prune_manifest_history(&transaction, folder_id, 1)?; transaction.commit()?; let remaining: i64 = store.connection.query_row( "SELECT COUNT(*) FROM manifest_history_compact WHERE folder_id = ?1", rusqlite::params![folder_id.to_string()], |row| row.get(0), )?;
assert_eq!(remaining, 1); Ok(())}
#[test]fn rejects_a_zero_history_revision_limit() { assert!(validate_history_revision_limit(0).is_err());}
#[test]fn keeps_ten_revisions_by_default() { assert_eq!(DEFAULT_HISTORY_REVISIONS, 10);}
#[test]fn gc_live_blob_query_fails_when_state_database_is_unavailable() { let directory = TempDir::new().expect("temporary directory"); assert!(StateStore::live_blob_hashes_for_gc(directory.path()).is_err());}
#[test]fn live_blob_query_propagates_manifest_read_failures() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; store.register_folder(&folder_path)?; store.connection.execute_batch("DROP TABLE manifests")?;
assert!(store.live_blob_hashes().is_err()); Ok(())}
#[test]fn live_blob_hashes_include_current_manifest_history_and_pending() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; let folder = store.register_folder(&folder_path)?;
let mut manifest = store.load_manifest(folder.id)?; manifest.entries.insert( "one.txt".to_owned(), test_file_entry_with_hash("one.txt", "blob-1"), ); manifest.entries.insert( "two.txt".to_owned(), test_file_entry_with_hash("two.txt", "blob-2"), ); store.save_manifest(&manifest)?;
let mut historical = manifest.clone(); historical.entries.insert( "old.txt".to_owned(), test_file_entry_with_hash("old.txt", "blob-old"), ); store.save_manifest(&historical)?; let mut current = historical.clone(); current.entries.remove("old.txt"); store.save_manifest(¤t)?;
let pending_entry = test_file_entry_with_hash("pending.txt", "blob-pending"); store.save_pending_materialization(folder.id, &[pending_entry], ¤t)?;
let live = store.live_blob_hashes()?; assert!(live.contains("blob-1")); assert!(live.contains("blob-2")); assert!(live.contains("blob-old")); assert!(live.contains("blob-pending"));
store.clear_pending_materialization(folder.id)?; let live_after_clear = store.live_blob_hashes()?; assert!(!live_after_clear.contains("blob-pending")); Ok(())}
#[test]fn live_blob_hashes_are_empty_for_a_folder_with_no_files() -> anyhow::Result<()> { let directory = TempDir::new()?; let folder_path = directory.path().join("folder"); fs::create_dir(&folder_path)?; let paths = AppPaths::from_data_directory(directory.path().join("state"))?; let store = StateStore::open(&paths)?; store.register_folder(&folder_path)?; assert!(store.live_blob_hashes()?.is_empty()); Ok(())}
#[test]fn gc_interval_has_a_default_and_rejects_values_below_the_minimum() -> anyhow::Result<()> { let interval = gc_interval()?; assert!(interval.as_secs() >= 60); Ok(())}
fn test_file_entry_with_hash(path: &str, blob_hash: &str) -> crate::domain::Entry { crate::domain::Entry { path: path.to_owned(), kind: crate::domain::EntryKind::File, blob_hash: Some(blob_hash.to_owned()), size_bytes: Some(1), modified_at: OffsetDateTime::now_utc(), clock: Default::default(), author_device_id: "device".to_owned(), }}