//! End to end against a real kestrove-server (ignored by default): //! //! ```sh //! KV_SETUP_CODE=… KV_BIND=127.0.0.1:18090 KV_PUBLIC_URL=http://127.0.0.1:18090 kestrove-server & //! KV_TEST_SERVER=http://127.0.0.1:18090 KV_TEST_SETUP_CODE=… cargo test --test live_sync -- --ignored --nocapture //! ``` //! //! Three "app instances" with separate data folders go through the same `SyncService` calls the UI makes: //! A creates the account; B joins with "Use my phone", approved from A with the pasted join code; items sync both //! ways; C uses the Emergency Kit code (no ticket), waits for approval, A approves it by fingerprint. use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; use kestrove_desktop::device_key::DeviceKey; use kestrove_desktop::sync::{SignIn, SyncService}; use kv_ffi::{ItemDto, ItemKindDto, VaultCore}; use zeroize::Zeroizing; const PASSWORD: &str = "correct horse battery staple"; fn instance(dir: &Path) -> Arc { let core = VaultCore::open(dir.join("vault.sqlite").to_string_lossy().into_owned()).unwrap(); let key = DeviceKey::load_or_create_file(dir).map_err(|e| e.to_string()); SyncService::new(core, dir, key, Box::new(|_| {})) } fn login(title: &str) -> ItemDto { ItemDto { id: String::new(), kind: ItemKindDto::Login, title: title.into(), username: Some(format!("{title}-user")), password: Some("hunter2".into()), urls: vec!["https://example.com".into()], totp: None, notes: None, favorite: false, tags: vec![], sign_in_with: None, modified_at: 0, } } fn titles(svc: &SyncService) -> Vec { svc.core().list_items().unwrap().into_iter().map(|i| i.title).collect() } /// The server allows 5 auth calls a minute per IP; three devices from one machine hit that. async fn patiently(mut f: F) -> Result where F: FnMut() -> Fut, Fut: std::future::Future>, { let start = Instant::now(); loop { match f().await { Err(e) if e.contains("Too many attempts") && start.elapsed() < Duration::from_secs(150) => { eprintln!("rate limited; waiting"); tokio::time::sleep(Duration::from_secs(15)).await; } other => return other, } } } #[tokio::test(flavor = "multi_thread")] #[ignore = "needs a running kestrove-server (KV_TEST_SERVER, KV_TEST_SETUP_CODE)"] async fn create_join_approve_and_sync() { let server = std::env::var("KV_TEST_SERVER").expect("KV_TEST_SERVER"); let code = std::env::var("KV_TEST_SETUP_CODE").expect("KV_TEST_SETUP_CODE"); let (da, db, dc) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); // A: "Create a new account". let a = instance(da.path()); let sk = a.create_account(&server, vec![], &code, "desktop@example.com", Zeroizing::new(PASSWORD.into())).await.unwrap(); assert!(sk.starts_with("K1")); assert!(a.status().logged_in); assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error); a.core().save_item(login("from-a")).unwrap(); a.sync_now().await.unwrap(); // A: "Add a device" QR, then Cancel revokes the ticket. let add = a.add_device().await.unwrap(); assert!(add.qr_svg.starts_with("data:image/svg+xml")); a.cancel_add_device().await; // B: "Use my phone" → the QR (and its text for the paste fallback). let b = instance(db.path()); let join = b.join_start(&server).await.unwrap(); assert!(join.qr_text.starts_with("kestrove://join/v1?")); let first = b.join_poll().await.unwrap(); assert!(first.code.is_none() && first.setup.is_none()); // A: "Approve a computer" with the pasted join code; both show the same code. let confirm = a.approve_join(&join.qr_text).await.unwrap(); assert_eq!(Some(confirm.key_fingerprint.clone()), b.status().device_fingerprint); tokio::time::sleep(Duration::from_millis(1100)).await; let seen = b.join_poll().await.unwrap(); assert_eq!(seen.code.as_deref(), Some(confirm.code.as_str())); a.send_join().await.unwrap(); // B: the bundle arrives; then only the master password. let setup = loop { tokio::time::sleep(Duration::from_millis(1100)).await; if let Some(s) = b.join_poll().await.unwrap().setup { break s; } }; assert!(setup.preapproved); assert_eq!(setup.server_url, server.trim_end_matches('/')); let r = patiently(|| b.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); assert_eq!(r, SignIn::Ok); assert_eq!(titles(&b), vec!["from-a"]); assert!(b.stored_secret_key().is_some(), "B remembers the Secret Key, so unlock needs only the password"); // An item from B reaches A. b.core().save_item(login("from-b")).unwrap(); b.sync_now().await.unwrap(); a.sync_now().await.unwrap(); let mut got = titles(&a); got.sort(); assert_eq!(got, vec!["from-a", "from-b"]); // Lock clears keys: no sync while locked, and unlocking with the password works again. b.core().lock(); b.on_lock().await; assert!(b.sync_now().await.is_err()); b.core().unlock(PASSWORD.into(), b.stored_secret_key().unwrap().to_string()).unwrap(); b.sync_now().await.unwrap(); // C: Emergency Kit code (no ticket) → pending → A approves after comparing fingerprints. let kit = a.core().setup_payload_for_kit().unwrap(); let c = instance(dc.path()); let view = c.setup_from_text(&kit).unwrap(); assert!(!view.preapproved); let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); assert_eq!(r, SignIn::PendingApproval); let devices = a.devices().await.unwrap(); let pending: Vec<_> = devices.iter().filter(|d| !d.approved && !d.revoked).collect(); assert_eq!(pending.len(), 1); assert_eq!(Some(pending[0].key_fingerprint.clone()), c.status().device_fingerprint); assert_eq!(a.status().pending_devices, 1); a.approve_device(&pending[0].device_id).await.unwrap(); let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap(); assert_eq!(r, SignIn::Ok); let mut got = titles(&c); got.sort(); assert_eq!(got, vec!["from-a", "from-b"]); // Revoke C from A; C can no longer sync. let c_id = pending[0].device_id.clone(); a.revoke_device(&c_id).await.unwrap(); assert!(a.devices().await.unwrap().iter().any(|d| d.device_id == c_id && d.revoked)); assert!(c.sync_now().await.is_err()); }