Profiles can be kept in step across machines through any folder they share (cloud drive, network share, git checkout): one file, nexus-profiles.nexus, in the encrypted-export format under a sync password kept in the keychain/vault. Each sync merges profile by profile (newest updated_at wins), propagates deletions as dated tombstones (an edit after a deletion brings the profile back; tombstones expire after 180 days), validates every incoming profile, writes the file only when it changed, and never syncs credentials. Settings → Security → Profile sync picks the folder and sets the password; syncs run at launch, 3 s after a profile change, every 10 minutes and on Sync Now (automatic runs stay quiet unless something changed). An end-to-end test drives create, edit and delete between two data directories sharing a folder.
52 lines
2.0 KiB
Rust
52 lines
2.0 KiB
Rust
//! Profile sync between two "machines" (data directories) sharing one folder.
|
|
|
|
use nexus_core::config::AppConfig;
|
|
use nexus_core::models::{Profile, Protocol};
|
|
use nexus_core::service::Service;
|
|
|
|
fn machine(root: &std::path::Path, name: &str, share: &std::path::Path) -> Service {
|
|
let cfg = AppConfig::in_dir(root.join(name));
|
|
std::fs::create_dir_all(&cfg.vault_dir).unwrap();
|
|
let svc = Service::open(cfg).unwrap();
|
|
svc.secrets.vault.initialise("master").unwrap();
|
|
let mut prefs = svc.preferences();
|
|
prefs.sync_folder = share.to_string_lossy().into_owned();
|
|
svc.save_preferences(&prefs).unwrap();
|
|
svc.set_sync_password("shared-secret").unwrap();
|
|
svc
|
|
}
|
|
|
|
#[test]
|
|
fn profiles_follow_edits_and_deletes_across_machines() {
|
|
let root = std::env::temp_dir().join(format!("nexus-sync-e2e-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
let share = root.join("share");
|
|
std::fs::create_dir_all(&share).unwrap();
|
|
let (a, b) = (machine(&root, "a", &share), machine(&root, "b", &share));
|
|
|
|
let mut p = Profile::new(Protocol::Ssh);
|
|
p.display_name = "web".into();
|
|
p.host = "web.example".into();
|
|
p.username = "me".into();
|
|
let p = a.create_profile(p).unwrap();
|
|
assert!(a.sync_profiles().unwrap().pushed);
|
|
assert_eq!(b.sync_profiles().unwrap().pulled, 1);
|
|
assert_eq!(b.profiles()[0].host, "web.example");
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(1100)); // updated_at has 1 s resolution
|
|
let mut edited = b.profiles()[0].clone();
|
|
edited.host = "web2.example".into();
|
|
b.update_profile(edited).unwrap();
|
|
b.sync_profiles().unwrap();
|
|
assert_eq!(a.sync_profiles().unwrap().pulled, 1);
|
|
assert_eq!(a.profiles()[0].host, "web2.example");
|
|
|
|
a.delete_profile(&p.id).unwrap();
|
|
a.sync_profiles().unwrap();
|
|
assert_eq!(b.sync_profiles().unwrap().removed, 1);
|
|
assert!(b.profiles().is_empty());
|
|
// Nothing left to do on either side.
|
|
assert!(!a.sync_profiles().unwrap().pushed && !b.sync_profiles().unwrap().pushed);
|
|
let _ = std::fs::remove_dir_all(root);
|
|
}
|