Files
samiandClaude Opus 5.5 a8f56ee00f
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Sign in with Google, Apple…: remember prompt, item row, new-login picker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:29:50 +07:00

159 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![],
sign_in_with: None,
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());
}