Create account takes an email and an invite or setup code (none with open sign-up), joins servers that already have accounts, and Settings shows the account email (admins manage people from the web vault). Co-Authored-By: Claude Opus 5.5 <[email protected]>
158 lines
6.5 KiB
Rust
158 lines
6.5 KiB
Rust
//! 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<SyncService> {
|
|
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![],
|
|
modified_at: 0,
|
|
}
|
|
}
|
|
|
|
fn titles(svc: &SyncService) -> Vec<String> {
|
|
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<T, F, Fut>(mut f: F) -> Result<T, String>
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: std::future::Future<Output = Result<T, String>>,
|
|
{
|
|
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, "[email protected]", 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());
|
|
}
|