//! In-browser tests (`wasm-pack test --headless --chrome crates/kv-wasm`). They drive the same //! `VaultCore` methods JavaScript calls (promises, serde conversion, IndexedDB, WebCrypto). use std::collections::BTreeMap; use js_sys::{Promise, Reflect}; use serde_json::{json, Value}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; use wasm_bindgen_test::*; use super::*; wasm_bindgen_test_configure!(run_in_browser); const PW: &str = "correct horse battery staple"; async fn ok(p: Promise) -> JsValue { match JsFuture::from(p).await { Ok(v) => v, Err(e) => panic!("promise rejected: {:?}", Reflect::get(&e, &"message".into()).unwrap_or(e)), } } async fn err_code(p: Promise) -> String { let e = JsFuture::from(p).await.expect_err("expected a rejection"); assert_eq!(Reflect::get(&e, &"name".into()).unwrap().as_string().as_deref(), Some("CoreError")); Reflect::get(&e, &"code".into()).unwrap().as_string().unwrap() } fn js(v: Value) -> JsValue { to_js(&v).unwrap() } fn de(v: JsValue) -> T { serde_wasm_bindgen::from_value(v).unwrap() } fn now_ms() -> f64 { js_sys::Date::now() } async fn open_db(name: &str) -> VaultCore { VaultCore::build(Some(idb::open(name).await.unwrap())).await.unwrap() } fn login(title: &str, password: &str) -> Value { json!({ "id": "", "kind": "login", "title": title, "username": "ana", "password": password, "urls": ["https://example.com/login"], "totp": null, "notes": null, "favorite": false, "tags": [], "modifiedAt": 0 }) } #[wasm_bindgen_test] async fn account_lifecycle_persists_in_indexeddb() { let db_name = format!("kv-test-{}", Uuid::new_v4()); let core = open_db(&db_name).await; assert!(!ok(core.has_account()).await.as_bool().unwrap()); let device_id = ok(core.device_id()).await.as_string().unwrap(); let t = now_ms(); let secret_key = ok(core.create_account(PW.into())).await.as_string().unwrap(); console_log!("createAccount (Argon2id 64 MiB, t=3, p=4): {:.0} ms", now_ms() - t); assert!(secret_key.starts_with("K1-")); let id = ok(core.save_item(js(login("Bank", "hunter2-secret")))).await.as_string().unwrap(); // Minimal input: omitted optional fields default. let note = ok(core.save_item(js(json!({"id": "", "kind": "secureNote", "title": "Note", "notes": "n"})))) .await .as_string() .unwrap(); let list: Vec = de(ok(core.list_items()).await); assert_eq!(list.iter().map(|i| i.title.as_str()).collect::>(), ["Bank", "Note"]); assert!(list[0].tags.is_empty() && list[0].modified_at > 1_700_000_000); let mut item: ItemDto = de(ok(core.get_item(id.clone())).await); assert_eq!(item.password.as_deref(), Some("hunter2-secret")); item.password = Some("hunter3-secret".into()); ok(core.save_item(to_js(&item).unwrap())).await; let history: Vec = de(ok(core.item_history(id.clone())).await); assert_eq!(history[0].password.as_deref(), Some("hunter2-secret")); ok(core.delete_item(note)).await; let for_url: Vec = de(ok(core.items_for_url("https://example.com/other".into())).await); assert_eq!(for_url.len(), 1); // Lock: every read needs the keys again. ok(core.lock()).await; assert!(ok(core.is_locked()).await.as_bool().unwrap()); assert_eq!(err_code(core.list_items()).await, "Locked"); assert_eq!(err_code(core.unlock("wrong password!".into(), secret_key.clone())).await, "BadCredentials"); assert_eq!(err_code(core.save_item(js(json!({"kind": "nope"})))).await, "Invalid"); // IndexedDB holds ciphertext only. let db = core.shared.db.clone().unwrap(); for (key, value) in idb::load_entries(&db).await.unwrap() { let text = String::from_utf8_lossy(&value); for secret in ["hunter2", "hunter3", "Bank", "ana", "example.com", PW] { assert!(!text.contains(secret), "{key} contains {secret}"); } } db.close(); drop(core); // A new page load: same device key, account still there, locked. let core = open_db(&db_name).await; assert_eq!(ok(core.device_id()).await.as_string().unwrap(), device_id); assert!(ok(core.has_account()).await.as_bool().unwrap()); assert!(ok(core.is_locked()).await.as_bool().unwrap()); let t = now_ms(); ok(core.unlock(PW.into(), secret_key.clone())).await; console_log!("unlock (Argon2id 64 MiB): {:.0} ms", now_ms() - t); let item: ItemDto = de(ok(core.get_item(id.clone())).await); assert_eq!(item.password.as_deref(), Some("hunter3-secret")); let list: Vec = de(ok(core.list_items()).await); assert_eq!(list.len(), 1); // Wipe: a new device, nothing left. ok(core.wipe()).await; assert!(!ok(core.has_account()).await.as_bool().unwrap()); assert_ne!(ok(core.device_id()).await.as_string().unwrap(), device_id); let db = core.shared.db.clone().unwrap(); assert!(idb::load_entries(&db).await.unwrap().is_empty()); db.close(); } #[wasm_bindgen_test] async fn webcrypto_device_key_signs_rfc9421_requests() { let (signer, _private) = WebCryptoSigner::generate().await.unwrap(); let pk = kv_sync::DeviceSigner::public_key(&signer); assert_eq!((pk.len(), pk[0]), (65, 4)); let url = "https://vault.example.com/v1/vaults"; let h = kv_sync::signing::sign_request_async(&signer, "PUT", url, b"{\"a\":1}", "session", 1_700_000_000) .await .unwrap(); let lookup = |name: &str| h.pairs().iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()); // The server-side verifier (p256) accepts the WebCrypto signature as is (raw r||s). let id = kv_sync::signing::verify_request(&pk, "PUT", url, b"{\"a\":1}", &lookup).unwrap(); assert_eq!(id, kv_sync::DeviceSigner::device_id(&signer)); assert!(kv_sync::signing::verify_request(&pk, "PUT", url, b"{}", &lookup).is_err()); } /// Items written by kv-ffi (native, SQLite) open in the browser. #[wasm_bindgen_test] async fn native_fixture_opens_in_wasm() { let f: Value = serde_json::from_str(include_str!("../../tests/fixtures/native-vault.json")).unwrap(); let entries: BTreeMap = serde_json::from_value(f["entries"].clone()).unwrap(); let store = WebStore::load(entries.into_iter().map(|(k, v)| (k, v.into_bytes()))).unwrap(); let core = VaultCore::build_with(store, None).await.unwrap(); let (pw, sk) = (f["masterPassword"].as_str().unwrap(), f["secretKey"].as_str().unwrap()); ok(core.unlock(pw.into(), sk.into())).await; let expected: Vec = serde_json::from_value(f["items"].clone()).unwrap(); for e in &expected { let got: ItemDto = de(ok(core.get_item(e.id.clone())).await); assert_eq!(&got, e); } let bank = expected.iter().find(|i| i.title == "Bank").unwrap(); let history: Vec = de(ok(core.item_history(bank.id.clone())).await); assert_eq!(history[0].password.as_deref(), Some("first password")); // Edits made here keep working with the native-made keys. let mut edit = bank.clone(); edit.notes = Some("edited in the browser".into()); ok(core.save_item(to_js(&edit).unwrap())).await; let got: ItemDto = de(ok(core.get_item(bank.id.clone())).await); assert_eq!(got.notes.as_deref(), Some("edited in the browser")); } #[wasm_bindgen_test] async fn in_memory_vault_import_export_and_helpers() { let core: VaultCore = VaultCore::build(None).await.unwrap(); assert_eq!(err_code(core.list_items()).await, "NoAccount"); assert_eq!(err_code(core.create_account("short".into())).await, "Invalid"); ok(core.create_account(PW.into())).await; let csv = b"title,url,username,password,otp,notes\nGit,https://git.example.com,me,pw1,,\n".to_vec(); let preview: ImportPreview = de(ok(core.import_preview(csv.clone(), js(json!({"type": "csv"})))).await); assert_eq!((preview.total, preview.logins), (1, 1)); let report: ImportReport = de(ok(core.import_commit(csv, js(json!({"type": "csv"})), js(json!({"duplicates": "skip"})))).await); assert_eq!(report.imported, 1); let t = now_ms(); let blob = ok(core.export_encrypted("export passphrase".into())).await; console_log!("exportEncrypted (Argon2id): {:.0} ms", now_ms() - t); let blob = js_sys::Uint8Array::new(&blob).to_vec(); let fmt = js(json!({"type": "kestroveEncrypted", "passphrase": "export passphrase"})); let preview: ImportPreview = de(ok(core.import_preview(blob, fmt)).await); assert_eq!((preview.total, preview.duplicates), (1, 1)); let passkeys: Vec = de(ok(core.list_passkeys()).await); assert!(passkeys.is_empty()); assert_eq!(err_code(core.delete_passkey(vec![1, 2, 3])).await, "NotFound"); // Not configured yet: network calls fail cleanly. assert_eq!(err_code(core.sync_now()).await, "NotConfigured"); assert_eq!(err_code(core.configure_server("http://example.com".into(), None)).await, "Invalid"); assert_eq!(generate_password(24, true).chars().count(), 24); let code: TotpCode = de(totp_code("JBSWY3DPEHPK3PXP".into(), 59.0).unwrap()); assert_eq!((code.code.len(), code.period, code.seconds_remaining), (6, 30, 1)); assert_eq!(match_url("https://a.example.com".into(), "https://a.example.com/x".into()).unwrap(), "exact"); // No Public Suffix List in the web build: different hosts never match. assert_eq!(match_url("https://a.example.com".into(), "https://b.example.com".into()).unwrap(), "none"); }