//! Envelope compatibility between the apps (kv-ffi) and the web vault (kv-wasm), through two shared //! fixtures in `tests/fixtures/`: //! //! * `native-vault.json`: made by kv-ffi (SQLite). The wasm tests (`src/wasm/tests.rs`, in Chrome) //! unlock it and read its items. //! * `wasm-vault.json`: made by the real wasm package (`js/smoke.mjs`, in Node). Here kv-ffi unlocks it //! and reads its items. //! //! Both hold the replica entries in the web store's format (ciphertext only), the credentials and the //! expected plaintext. Regenerate the native one with //! `cargo test -p kv-wasm --test fixture -- --ignored generate_native_fixture`. #![cfg(not(target_arch = "wasm32"))] use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; use kv_store::{SqliteStore, Store}; use kv_wasm::{ dto::{ItemDto, ItemKindDto}, store::WebStore, WebCore, }; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Fixture { master_password: String, secret_key: String, /// Replica entries, values as UTF-8 (they are JSON or ASCII). entries: BTreeMap, /// Expected plaintext, sorted by title. items: Vec, } fn path(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name) } fn load(name: &str) -> Fixture { serde_json::from_slice(&std::fs::read(path(name)).unwrap()).unwrap() } /// Copies a native SQLite replica into the web store format. fn sqlite_to_web(src: &SqliteStore) -> WebStore { let dst = WebStore::new(); for key in ["account", "server", "session"] { if let Some(v) = src.get_meta(key).unwrap() { dst.put_meta(key, &v).unwrap(); } } for v in src.vaults().unwrap() { dst.put_vault(&v).unwrap(); for item in src.list(v.vault_id).unwrap() { dst.put(v.vault_id, &item).unwrap(); for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() { dst.push_history(v.vault_id, item.record.item_id, &h).unwrap(); } } } dst } /// The reverse: a web replica written into a fresh SQLite database kv-ffi can open. fn web_to_sqlite(src: &WebStore, dst: &SqliteStore) { for key in ["account", "server", "session"] { if let Some(v) = src.get_meta(key).unwrap() { dst.put_meta(key, &v).unwrap(); } } for v in src.vaults().unwrap() { dst.put_vault(&v).unwrap(); for item in src.list(v.vault_id).unwrap() { dst.put(v.vault_id, &item).unwrap(); for h in src.history(v.vault_id, item.record.item_id).unwrap().into_iter().rev() { dst.push_history(v.vault_id, item.record.item_id, &h).unwrap(); } } } } fn web_store(f: &Fixture) -> WebStore { WebStore::load(f.entries.iter().map(|(k, v)| (k.clone(), v.as_bytes().to_vec()))).unwrap() } /// Items as kv-ffi sees them, sorted by title, `modified_at` included. fn read_with_kv_ffi(f: &Fixture) -> Vec { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("vault.db"); web_to_sqlite(&web_store(f), &SqliteStore::open(&db).unwrap()); let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap(); assert!(core.is_locked()); core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap(); let mut out: Vec = core .list_items() .unwrap() .into_iter() .map(|s| { let d = core.get_item(s.id).unwrap(); ItemDto { id: d.id, kind: match d.kind { kv_ffi::ItemKindDto::Login => ItemKindDto::Login, kv_ffi::ItemKindDto::SecureNote => ItemKindDto::SecureNote, kv_ffi::ItemKindDto::Card => ItemKindDto::Card, kv_ffi::ItemKindDto::Identity => ItemKindDto::Identity, }, title: d.title, username: d.username, password: d.password, urls: d.urls, totp: d.totp, notes: d.notes, favorite: d.favorite, tags: d.tags, modified_at: d.modified_at, } }) .collect(); out.sort_by(|a, b| a.title.cmp(&b.title)); out } fn read_with_web_core(f: &Fixture) -> Vec { let core = WebCore::new(Arc::new(web_store(f))).unwrap(); core.unlock(&f.master_password, &f.secret_key).unwrap(); let mut out: Vec = core.list_items().unwrap().into_iter().map(|s| core.get_item(&s.id).unwrap()).collect(); out.sort_by(|a, b| a.title.cmp(&b.title)); out } fn ffi_item(title: &str, kind: kv_ffi::ItemKindDto) -> kv_ffi::ItemDto { kv_ffi::ItemDto { id: String::new(), kind, title: title.into(), username: None, password: None, urls: vec![], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, } } #[test] #[ignore = "writes tests/fixtures/native-vault.json"] fn generate_native_fixture() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("vault.db"); let pw = "native fixture master password"; let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap(); let secret_key = core.create_account(pw.into()).unwrap(); let bank = core .save_item(kv_ffi::ItemDto { username: Some("ana@example.com".into()), password: Some("first password".into()), urls: vec!["https://bank.example.com/login".into()], totp: Some("otpauth://totp/Bank:ana?secret=JBSWY3DPEHPK3PXP&issuer=Bank".into()), tags: vec!["finance".into(), "ünïcode ✓".into()], favorite: true, ..ffi_item("Bank", kv_ffi::ItemKindDto::Login) }) .unwrap(); // A second version, so the history entry crosses over too. let mut edit = core.get_item(bank.clone()).unwrap(); edit.password = Some("second password".into()); core.save_item(edit).unwrap(); core.save_item(kv_ffi::ItemDto { notes: Some("multi\nline\nnote with emoji 🔐".into()), ..ffi_item("Recovery codes", kv_ffi::ItemKindDto::SecureNote) }) .unwrap(); drop(core); let web = sqlite_to_web(&SqliteStore::open(&db).unwrap()); let mut f = Fixture { master_password: pw.into(), secret_key, entries: web.entries().into_iter().map(|(k, v)| (k, String::from_utf8(v).unwrap())).collect(), items: vec![], }; f.items = read_with_kv_ffi(&f); std::fs::write(path("native-vault.json"), serde_json::to_string_pretty(&f).unwrap() + "\n").unwrap(); } #[test] fn native_fixture_opens_in_kv_ffi_and_web_core() { let f = load("native-vault.json"); assert_eq!(f.items.len(), 2); assert_eq!(read_with_kv_ffi(&f), f.items); assert_eq!(read_with_web_core(&f), f.items); // History made by kv-ffi decrypts in the web core. let core = WebCore::new(Arc::new(web_store(&f))).unwrap(); core.unlock(&f.master_password, &f.secret_key).unwrap(); let bank = f.items.iter().find(|i| i.title == "Bank").unwrap(); let h = core.item_history(&bank.id).unwrap(); assert_eq!(h[0].password.as_deref(), Some("first password")); } /// Made by the wasm build (`node crates/kv-wasm/js/smoke.mjs`), opened by kv-ffi. #[test] fn wasm_fixture_opens_in_kv_ffi() { let f = load("wasm-vault.json"); assert!(!f.items.is_empty()); assert_eq!(read_with_kv_ffi(&f), f.items); // kv-ffi can keep using the vault: edit, and the web core reads the edit back. let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("vault.db"); web_to_sqlite(&web_store(&f), &SqliteStore::open(&db).unwrap()); let core = kv_ffi::VaultCore::open(db.to_string_lossy().into_owned()).unwrap(); core.unlock(f.master_password.clone(), f.secret_key.clone()).unwrap(); let first = core.list_items().unwrap().remove(0); let mut d = core.get_item(first.id.clone()).unwrap(); d.notes = Some("edited natively".into()); core.save_item(d).unwrap(); drop(core); let back = sqlite_to_web(&SqliteStore::open(&db).unwrap()); let web = WebCore::new(Arc::new(back)).unwrap(); web.unlock(&f.master_password, &f.secret_key).unwrap(); assert_eq!(web.get_item(&first.id).unwrap().notes.as_deref(), Some("edited natively")); }