Files
kestrove-core/crates/kv-wasm/tests/fixture.rs
T
samiandClaude Opus 5.5 20d465185d
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s
Sign in with Google, Apple…: item field, bridge save and identities
Items record sign_in_with {provider, account}; the browser bridge reports it
with the site's identities and saves it after the usual app prompt. Items
now keep fields they don't know, so editing in an older app never drops what
a newer one added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:19:14 +07:00

226 lines
8.5 KiB
Rust

//! 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<String, String>,
/// Expected plaintext, sorted by title.
items: Vec<ItemDto>,
}
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<ItemDto> {
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<ItemDto> = 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,
sign_in_with: d.sign_in_with.map(|w| kv_model::SignInWith { provider: w.provider, account: w.account }),
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<ItemDto> {
let core = WebCore::new(Arc::new(web_store(f))).unwrap();
core.unlock(&f.master_password, &f.secret_key).unwrap();
let mut out: Vec<ItemDto> = 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![],
sign_in_with: None,
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("[email protected]".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"));
}