Files
kestrove-core/crates/kv-wasm/src/wasm/tests.rs
T

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");
}