200 lines
9.3 KiB
Rust
200 lines
9.3 KiB
Rust
//! 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<T: DeserializeOwned>(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<ItemSummary> = de(ok(core.list_items()).await);
|
|
assert_eq!(list.iter().map(|i| i.title.as_str()).collect::<Vec<_>>(), ["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<ItemDto> = 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<ItemSummary> = 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<ItemSummary> = 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<String, String> = 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<ItemDto> = 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<ItemDto> = 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<PasskeySummary> = 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");
|
|
}
|