use 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::, _>>()?; 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 { 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(), } }