//! The web core (compiled natively here, same code as in the browser) syncing with an app device //! (kv-ffi) through kv-sync's in-memory test server: enrollment by approval, both directions of //! edits, deletes, and a restart from the persisted replica. #![cfg(not(target_arch = "wasm32"))] use std::sync::Arc; use kv_ffi::{DeviceSigner, PlatformDto}; use kv_proto::Platform; use kv_sync::{ signing::SoftwareSigner, test_server::{TestServer, SETUP_TOKEN}, }; use kv_wasm::{ dto::{ItemDto, ItemKindDto, LoginResult}, store::WebStore, CoreError, WebCore, }; use uuid::Uuid; const PW: &str = "a long master password"; struct Signer(SoftwareSigner); impl DeviceSigner for Signer { fn device_id(&self) -> String { kv_sync::DeviceSigner::device_id(&self.0).to_string() } fn public_key(&self) -> Vec { kv_sync::DeviceSigner::public_key(&self.0) } fn sign(&self, message: Vec) -> Result, kv_ffi::CoreError> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() }) } } fn web_item(title: &str, password: &str) -> ItemDto { ItemDto { id: String::new(), kind: ItemKindDto::Login, title: title.into(), username: Some("me".into()), password: Some(password.into()), urls: vec!["https://example.com".into()], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, } } fn web_core(store: Arc, url: &str, signer: Arc) -> WebCore { let core = WebCore::new(store).unwrap(); core.configure_server(url, vec![]).unwrap(); core.set_device_signer(signer, "Web vault", Platform::Macos); core } #[tokio::test] async fn web_vault_enrolls_and_syncs_with_an_app_device() { let server = TestServer::start().await; // The app (kv-ffi) is the first device. let app = kv_ffi::VaultCore::new_in_memory(); let sk = app.create_account(PW.into()).unwrap(); app.configure_server(server.url(), vec![]).unwrap(); app.set_device_signer(Arc::new(Signer(SoftwareSigner::generate(Uuid::new_v4()))), "phone".into(), PlatformDto::Ios) .unwrap(); app.register_account(SETUP_TOKEN.into()).await.unwrap(); let mut on_app = kv_ffi::ItemDto { id: String::new(), kind: kv_ffi::ItemKindDto::Login, title: "Bank".into(), username: Some("me".into()), password: Some("from the app".into()), urls: vec![], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, }; let bank = app.save_item(on_app.clone()).unwrap(); app.sync_now().await.unwrap(); // The browser logs in as a new device and waits for approval. server.state().require_approval = true; let store = Arc::new(WebStore::new()); let signer = Arc::new(SoftwareSigner::generate(Uuid::new_v4())); let web = web_core(store.clone(), &server.url(), signer.clone()); assert!(matches!(web.sync_now().await, Err(CoreError::NoAccount | CoreError::Locked))); assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::PendingApproval); assert!(!web.has_account()); let pending = app.list_devices().await.unwrap().into_iter().find(|d| !d.approved).unwrap(); assert_eq!(pending.key_fingerprint, web.device_key_fingerprint().unwrap()); app.approve_device(pending.device_id).await.unwrap(); assert_eq!(web.login(PW, &sk, None).await.unwrap(), LoginResult::Ok); let r = web.sync_now().await.unwrap(); assert_eq!((r.pulled, r.vaults_synced), (1, 1)); assert_eq!(web.get_item(&bank).unwrap().password.as_deref(), Some("from the app")); // Browser edit and new item reach the app. let mut edit = web.get_item(&bank).unwrap(); edit.notes = Some("edited in the browser".into()); web.save_item(edit).unwrap(); let new_id = web.save_item(web_item("Mail", "from the web")).unwrap(); assert_eq!(web.sync_now().await.unwrap().pushed, 2); app.sync_now().await.unwrap(); assert_eq!(app.get_item(bank.clone()).unwrap().notes.as_deref(), Some("edited in the browser")); assert_eq!(app.get_item(new_id.clone()).unwrap().password.as_deref(), Some("from the web")); // Concurrent edits of different fields merge. on_app = app.get_item(bank.clone()).unwrap(); on_app.password = Some("rotated on the app".into()); app.save_item(on_app).unwrap(); let mut edit = web.get_item(&bank).unwrap(); edit.tags = vec!["web".into()]; web.save_item(edit).unwrap(); app.sync_now().await.unwrap(); let r = web.sync_now().await.unwrap(); assert_eq!(r.merged, 1); app.sync_now().await.unwrap(); for item in [web.get_item(&bank).unwrap(), { let d = app.get_item(bank.clone()).unwrap(); ItemDto { password: d.password, tags: d.tags, ..web.get_item(&bank).unwrap() } }] { assert_eq!(item.password.as_deref(), Some("rotated on the app")); assert_eq!(item.tags, vec!["web".to_string()]); } // Delete in the browser; the app sees it. web.delete_item(&new_id).unwrap(); web.sync_now().await.unwrap(); app.sync_now().await.unwrap(); assert!(matches!(app.get_item(new_id), Err(kv_ffi::CoreError::NotFound))); // A page reload: the replica comes back from its persisted entries, locked, session kept. let reloaded = Arc::new(WebStore::load(store.entries()).unwrap()); let web = web_core(reloaded, &server.url(), signer); assert!(web.has_account() && web.is_locked() && web.is_logged_in()); assert!(matches!(web.list_items(), Err(CoreError::Locked))); web.unlock(PW, &sk).unwrap(); assert_eq!(web.sync_now().await.unwrap().pulled, 0); assert_eq!(web.list_items().unwrap().len(), 1); // Revoked by the app: the browser can no longer sync. let me = web.device_id().unwrap().to_string(); app.revoke_device(me).await.unwrap(); assert!(web.sync_now().await.is_err()); } #[tokio::test] async fn concurrent_sync_is_refused_not_interleaved() { let server = TestServer::start().await; let web = web_core(Arc::new(WebStore::new()), &server.url(), Arc::new(SoftwareSigner::generate(Uuid::new_v4()))); let sk = web.create_account(PW).unwrap(); web.register_account(SETUP_TOKEN).await.unwrap(); web.save_item(web_item("A", "1")).unwrap(); let (a, b) = tokio::join!(web.sync_now(), web.sync_now()); assert!(matches!((&a, &b), (Ok(_), Err(CoreError::Busy)) | (Err(CoreError::Busy), Ok(_))), "{a:?} {b:?}"); assert!(web.sync_now().await.is_ok()); web.lock(); web.unlock(PW, &sk).unwrap(); }