Files
buzz/desktop/src-tauri/src/app_state_tests.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1418 lines
57 KiB
Rust

use super::*;
fn assert_key_eq(a: &Keys, b: &Keys) {
assert_eq!(a.public_key().to_hex(), b.public_key().to_hex());
}
/// `BUZZ_PRIVATE_KEY` is process-global; serialize the env-mutating tests
/// so they don't race each other under the parallel test runner.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `body` with `BUZZ_PRIVATE_KEY` set to `value` (or unset when `None`),
/// restoring the prior value afterward.
fn with_env_key<T>(value: Option<&str>, body: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var("BUZZ_PRIVATE_KEY").ok();
match value {
Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v),
None => std::env::remove_var("BUZZ_PRIVATE_KEY"),
}
let out = body();
match prior {
Some(v) => std::env::set_var("BUZZ_PRIVATE_KEY", v),
None => std::env::remove_var("BUZZ_PRIVATE_KEY"),
}
out
}
#[test]
fn identity_from_env_wins_when_valid() {
let configured = Keys::generate();
let nsec = configured.secret_key().to_bech32().unwrap();
let resolved =
with_env_key(Some(&nsec), identity_from_env).expect("valid env key must resolve");
assert_key_eq(&configured, &resolved);
}
#[test]
fn identity_from_env_none_when_absent() {
assert!(with_env_key(None, identity_from_env).is_none());
}
#[test]
fn identity_from_env_none_when_malformed() {
// A malformed env var falls through to persisted resolution rather than
// winning — otherwise a typo'd key would silently shadow the real one.
assert!(with_env_key(Some("not-a-valid-nsec"), identity_from_env).is_none());
}
#[test]
fn save_and_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
let keys = Keys::generate();
save_key_file(&path, &keys).unwrap();
let loaded = load_key_file(&path).unwrap();
assert_key_eq(&keys, &loaded);
}
#[test]
fn load_rejects_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
std::fs::write(&path, "").unwrap();
assert!(load_key_file(&path).is_err());
}
#[test]
fn load_rejects_corrupt_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
std::fs::write(&path, "not-a-valid-nsec").unwrap();
assert!(load_key_file(&path).is_err());
}
#[test]
fn load_missing_file_is_err() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.key");
assert!(load_key_file(&path).is_err());
}
#[test]
fn cleanup_removes_leftover_identity_file() {
// Item 1: a leftover identity.key (from a migration whose remove_file
// failed) is deleted once the keyring is authoritative, so plaintext
// does not linger on disk.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
save_key_file(&path, &Keys::generate()).unwrap();
assert!(path.exists());
cleanup_leftover_identity_file(&path);
assert!(!path.exists());
}
#[test]
fn cleanup_is_noop_when_no_leftover_file() {
// Idempotent: the cleanup runs on every keyring-Present boot, so a
// missing file must be a silent success, not an error or panic.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
assert!(!path.exists());
cleanup_leftover_identity_file(&path);
assert!(!path.exists());
}
#[test]
fn save_creates_file_with_valid_nsec() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
let keys = Keys::generate();
save_key_file(&path, &keys).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("nsec1"));
}
#[cfg(unix)]
#[test]
fn save_creates_file_with_restricted_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
let keys = Keys::generate();
save_key_file(&path, &keys).unwrap();
let perms = std::fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
fn save_overwrites_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identity.key");
let keys1 = Keys::generate();
save_key_file(&path, &keys1).unwrap();
let keys2 = Keys::generate();
save_key_file(&path, &keys2).unwrap();
let loaded = load_key_file(&path).unwrap();
assert_key_eq(&keys2, &loaded);
}
use std::cell::RefCell;
use std::collections::HashMap;
use crate::secret_store::KeyringProbe;
/// In-memory [`IdentityKeyStore`] for testing identity recovery without the
/// OS keyring. Seeded with an initial value and a probe outcome; records
/// every `delete`/`store` so tests can assert the keyring was cleared and
/// rewritten. `write_and_verify` succeeds (store then load reflects it).
struct FakeIdentityStore {
probe: KeyringProbe,
slot: RefCell<HashMap<String, String>>,
deleted: RefCell<Vec<String>>,
/// When true, `store` returns an availability error, driving the
/// keyring-write-failure → file-fallback arm of `store_key_preferring_keyring`.
store_fails: bool,
/// When `Some`, `load()` always returns this value regardless of what was
/// stored. Used to simulate read-back corruption: `store()` succeeds but
/// the subsequent `load()` returns a different value, causing
/// `persist_identity_to_keyring`'s read-back verify to fail.
load_override: Option<String>,
/// When true, `verify_stored()` always returns `Ok(false)` — simulates
/// a backend that stores successfully but cannot be read back (e.g. an OS
/// keyring that advances its in-process cache but fails to durably persist).
verify_fails: bool,
}
impl FakeIdentityStore {
fn present_with(value: &str) -> Self {
let mut slot = HashMap::new();
slot.insert(IDENTITY_KEY_NAME.to_string(), value.to_string());
Self {
probe: KeyringProbe::Present,
slot: RefCell::new(slot),
deleted: RefCell::new(Vec::new()),
store_fails: false,
load_override: None,
verify_fails: false,
}
}
/// Backend down this boot: probe is `Unreachable` and the slot is empty
/// (the real key, if any, is in the keyring we cannot reach).
fn unreachable() -> Self {
Self {
probe: KeyringProbe::Unreachable,
slot: RefCell::new(HashMap::new()),
deleted: RefCell::new(Vec::new()),
store_fails: false,
load_override: None,
verify_fails: false,
}
}
/// Backend reachable with no entry — drives the one-time migration path.
/// `store`/`load` go through the slot, so a read-back verify succeeds.
fn reachable_but_empty() -> Self {
Self {
probe: KeyringProbe::ReachableButEmpty,
slot: RefCell::new(HashMap::new()),
deleted: RefCell::new(Vec::new()),
store_fails: false,
load_override: None,
verify_fails: false,
}
}
/// Present probe seeded with a value but whose `store` always fails —
/// exercises the keyring-write-failure arm of adoption and import paths.
fn present_with_store_failing(value: &str) -> Self {
let mut slot = HashMap::new();
slot.insert(IDENTITY_KEY_NAME.to_string(), value.to_string());
Self {
probe: KeyringProbe::Present,
slot: RefCell::new(slot),
deleted: RefCell::new(Vec::new()),
store_fails: true,
load_override: None,
verify_fails: false,
}
}
/// Reachable-but-empty probe whose `store` always fails — exercises the
/// keyring-write-failure → `0o600` file-fallback arm.
fn store_failing() -> Self {
Self {
probe: KeyringProbe::ReachableButEmpty,
slot: RefCell::new(HashMap::new()),
deleted: RefCell::new(Vec::new()),
store_fails: true,
load_override: None,
verify_fails: false,
}
}
/// Reachable-but-empty probe whose `store` succeeds but whose `load`
/// always returns `corrupt_nsec` — simulates keyring read-back corruption.
/// `persist_identity_to_keyring`'s read-back verify sees a mismatch and
/// returns `Err("keyring read-back verify failed")`.
fn with_readback_corruption(corrupt_nsec: &str) -> Self {
Self {
probe: KeyringProbe::ReachableButEmpty,
slot: RefCell::new(HashMap::new()),
deleted: RefCell::new(Vec::new()),
store_fails: false,
load_override: Some(corrupt_nsec.to_string()),
verify_fails: false,
}
}
/// Reachable-but-empty probe whose `store` succeeds but whose
/// `verify_stored` always returns `Ok(false)` — simulates a backend that
/// writes to a cache but cannot confirm the OS-level round-trip.
/// `persist_identity_to_keyring` will treat this as a read-back failure.
fn with_verify_failing() -> Self {
Self {
probe: KeyringProbe::ReachableButEmpty,
slot: RefCell::new(HashMap::new()),
deleted: RefCell::new(Vec::new()),
store_fails: false,
load_override: None,
verify_fails: true,
}
}
}
impl IdentityKeyStore for FakeIdentityStore {
fn probe(&self, _name: &str) -> KeyringProbe {
self.probe
}
fn load(&self, name: &str) -> Result<Option<String>, String> {
if let Some(v) = &self.load_override {
return Ok(Some(v.clone()));
}
Ok(self.slot.borrow().get(name).cloned())
}
fn store(&self, name: &str, value: &str) -> Result<(), String> {
if self.store_fails {
return Err("simulated keyring write failure".to_string());
}
self.slot
.borrow_mut()
.insert(name.to_string(), value.to_string());
Ok(())
}
fn delete(&self, name: &str) -> Result<(), String> {
self.deleted.borrow_mut().push(name.to_string());
self.slot.borrow_mut().remove(name);
Ok(())
}
fn verify_stored(&self, name: &str, expected: &str) -> Result<bool, String> {
if self.verify_fails {
return Ok(false);
}
// When load_override is set, verify_stored must also reflect the
// override — the override simulates a backend that returns a different
// value regardless of what was stored, so both load() and verify_stored()
// should see it. This mirrors the real `with_readback_corruption` scenario.
if let Some(v) = &self.load_override {
return Ok(v == expected);
}
Ok(self.slot.borrow().get(name).is_some_and(|v| v == expected))
}
}
#[test]
fn corrupt_keyring_recovers_valid_file_without_rotating() {
// The load-bearing regression guard. When the keyring holds a corrupt
// nsec (Present) AND a valid `identity.key` is on disk (leftover from a
// failed prior migration), recovery must RECOVER THE FILE'S identity —
// not quarantine the file and rotate to a fresh key (the original
// hazard). The corrupt keyring value must be cleared and replaced by the
// file's key (migrated in).
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
let store = FakeIdentityStore::present_with("not-a-valid-nsec");
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The FILE's identity is recovered — NOT a freshly generated one.
assert_key_eq(&file_keys, &resolved.keys);
// The corrupt keyring value was cleared.
assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]);
// The keyring now holds the file's key (migrated in, read-back verified).
let file_nsec = file_keys.secret_key().to_bech32().unwrap();
assert_eq!(
store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.map(String::as_str),
Some(file_nsec.as_str())
);
// The valid file was migrated (deleted), not quarantined to .bad.*.
assert!(!legacy_path.exists());
assert!(std::fs::read_dir(dir.path()).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.contains(".bad.")));
}
#[test]
fn corrupt_keyring_generates_fresh_only_when_no_file() {
// With a corrupt keyring value and NO file on disk, generate-fresh is
// the correct last resort — and the corrupt keyring value is cleared
// first.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists());
let store = FakeIdentityStore::present_with("not-a-valid-nsec");
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]);
// A fresh, valid key was persisted to the keyring (replacing the cleared
// corrupt value).
let stored = store.slot.borrow().get(IDENTITY_KEY_NAME).cloned();
assert_eq!(
stored.as_deref(),
Some(resolved.keys.secret_key().to_bech32().unwrap().as_str())
);
}
#[test]
fn valid_keyring_is_used_and_matching_leftover_file_cleaned_up() {
// A valid keyring entry and a leftover identity.key with the SAME pubkey
// (stale leftover from a migration whose remove_file previously failed):
// keyring wins, plaintext is removed without adoption.
let keyring_keys = Keys::generate();
let nsec = keyring_keys.secret_key().to_bech32().unwrap();
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
// Same key in file as keyring → stale leftover, not an import.
save_key_file(&legacy_path, &keyring_keys).unwrap();
let store = FakeIdentityStore::present_with(&nsec);
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_key_eq(&keyring_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
assert!(store.deleted.borrow().is_empty());
assert!(!legacy_path.exists());
}
#[test]
fn unreachable_post_migration_boots_keyring_locked_recovery() {
// After a migration the file is gone and the marker exists. A later boot
// with the keyring unreachable must NOT generate a fresh key (that would
// silently rotate the identity), but must also allow the app to open
// instead of hard-aborting. The result is a keyring-locked recovery boot:
// ephemeral key held in memory only, nothing persisted anywhere.
//
// Fail-closed semantics are preserved: no identity is ever written to disk
// or the keyring under the ephemeral key, so no silent rotation occurs.
// The abort is replaced by a graceful recovery screen.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists());
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// KeyringLocked recovery: ephemeral key returned, nothing persisted.
assert_eq!(resolved.recovery, RecoveryState::KeyringLocked);
// No identity.key was written.
assert!(!legacy_path.exists());
// Keyring store was never called (it is unreachable).
assert!(store.slot.borrow().is_empty());
assert!(store.deleted.borrow().is_empty());
}
#[test]
fn unreachable_first_run_generates_to_file_when_no_marker() {
// Genuine first-EVER launch on a machine whose keyring is down: no file,
// no marker. There is no prior identity to protect, so generating to the
// `0o600` file is correct — fail-closed here would block a legitimate
// first launch.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists());
assert!(!migration_marker_path(dir.path()).exists());
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// A fresh key was generated and persisted to the file (keyring is down).
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
}
#[test]
fn migration_writes_marker_before_deleting_file() {
// Crash-safe ordering: a successful migration must leave the marker on
// disk AND remove the file. The marker existing while the file is gone
// is the durable post-migration signal the Unreachable arm relies on;
// "file gone, no marker" must never be the resting state.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
// ReachableButEmpty drives the one-time migration path.
let store = FakeIdentityStore::reachable_but_empty();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_key_eq(&file_keys, &resolved.keys);
// Marker written, file deleted — the safe resting state.
assert!(migration_marker_path(dir.path()).exists());
assert!(!legacy_path.exists());
}
#[test]
fn fresh_keyring_generate_writes_marker() {
// Fix 1 (Pinky comment 1): a fresh install generating straight into a
// reachable-but-empty keyring must write the marker. Without it, "no
// file, no marker" matches a never-launched machine, so a later
// Unreachable boot would silently rotate the key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists());
let store = FakeIdentityStore::reachable_but_empty();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The key was stored in the keyring (not the file), and the marker marks it.
assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring);
assert!(migration_marker_path(dir.path()).exists());
assert_eq!(
store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.map(String::as_str),
Some(resolved.keys.secret_key().to_bech32().unwrap().as_str())
);
}
#[test]
fn fresh_keyring_generate_then_unreachable_boots_locked_recovery() {
// End-to-end guard for Fix 1: after a fresh keyring-created identity
// (marker written, no file), a later boot with the keyring unreachable
// must NOT generate a new key and rotate identity. Instead it boots
// keyring-locked recovery — the real key is still in the keyring.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
// First boot: fresh generate into a reachable keyring.
let reachable = FakeIdentityStore::reachable_but_empty();
resolve_identity_with_store(&reachable, &legacy_path, dir.path()).unwrap();
assert!(!legacy_path.exists());
assert!(migration_marker_path(dir.path()).exists());
// Second boot: keyring is down. No file + marker present → locked recovery.
let unreachable = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&unreachable, &legacy_path, dir.path()).unwrap();
assert_eq!(
resolved.recovery,
RecoveryState::KeyringLocked,
"second boot must boot keyring-locked, not generate a fresh key"
);
// No identity.key was written — nothing new persisted.
assert!(!legacy_path.exists());
}
#[test]
fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() {
// Fix 1 correctness on the file-fallback arm: when the keyring write
// FAILS during a fresh generate, the key must land in the `0o600` file
// and the marker must NOT be written — a marker here would wrongly trip
// the next Unreachable boot into failing closed even though the key is
// sitting in the file.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let store = FakeIdentityStore::store_failing();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// Key persisted to the file (fallback), and recoverable from it.
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
// No marker: the file is the authoritative store, not the keyring.
assert!(
!migration_marker_path(dir.path()).exists()
&& resolved.storage == IdentityStorage::LocalFile
);
}
// ── New tests for the three defects fixed in this PR ─────────────────────
#[test]
fn import_persists_to_keyring_reboot_resolves_imported_pubkey() {
// (a) import persists to keyring → simulated reboot resolves the
// imported pubkey.
//
// `persist_identity_to_keyring` is the kernel called by
// `import_identity`. After it succeeds the keyring slot holds the
// imported nsec. A fresh store seeded with that nsec (simulating a
// reboot where the keyring has the value) must resolve to the same key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let imported_keys = Keys::generate();
// Simulate what import_identity does: persist to keyring.
let store_import = FakeIdentityStore::reachable_but_empty();
persist_identity_to_keyring(&store_import, &imported_keys, &legacy_path, dir.path())
.expect("persist_identity_to_keyring must succeed with a reachable store");
// Keyring slot now holds the imported nsec.
let stored_nsec = store_import
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.cloned()
.expect("keyring must hold the imported nsec after persist");
assert_eq!(stored_nsec, imported_keys.secret_key().to_bech32().unwrap());
// Simulated reboot: new store with Present probe, seeded with the stored nsec.
let store_reboot = FakeIdentityStore::present_with(&stored_nsec);
let resolved = resolve_identity_with_store(&store_reboot, &legacy_path, dir.path()).unwrap();
// The resolved key is the imported one — identity survives the reboot.
assert_key_eq(&imported_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
// No identity.key left on disk (was deleted by persist_identity_to_keyring).
assert!(!legacy_path.exists());
}
#[test]
fn present_keyring_with_mismatched_file_adopts_file_key() {
// (b) Present + mismatched identity.key → file's key adopted into
// keyring, no data loss, file removed.
//
// This auto-heals installs already stuck in the re-onboarding loop:
// the keyring holds the shadow key generated at first launch, while
// identity.key holds the user's imported key from a subsequent import
// that only reached the file (pre-fix bug). Resolution must adopt the
// file's key as the user's explicit intent.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let keyring_keys = Keys::generate();
let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap();
// identity.key has a DIFFERENT key — the user's import.
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
let store = FakeIdentityStore::present_with(&keyring_nsec);
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The file's key (user's explicit import) wins.
assert_key_eq(&file_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
// The keyring now holds the file's key (overwritten with read-back verify).
let file_nsec = file_keys.secret_key().to_bech32().unwrap();
assert_eq!(
store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.map(String::as_str),
Some(file_nsec.as_str())
);
// identity.key was removed after adoption.
assert!(!legacy_path.exists());
// Migration marker was written before file removal (crash-safe ordering).
// Without the marker, a later keyring-unreachable boot would see no file
// and no marker and silently generate a fresh key.
let marker_path = migration_marker_path(dir.path());
assert!(
marker_path.exists(),
"migration marker must exist after mismatched-file adoption"
);
}
#[test]
fn present_keyring_mismatched_file_adoption_store_failure_boots_with_file_key() {
// Present + mismatched identity.key + keyring write fails during adoption.
// Boot must succeed with the FILE's key (the user's intent). The file must
// survive on disk because the write was rejected — adoption retries on the
// next boot when the keyring is reachable. The keyring slot must be
// unchanged (shadow nsec still present, not overwritten).
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let keyring_keys = Keys::generate();
let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap();
// identity.key has a DIFFERENT key — the user's import.
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
let store = FakeIdentityStore::present_with_store_failing(&keyring_nsec);
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// File key (user's explicit import) is returned.
assert_key_eq(&file_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
// identity.key must survive — adoption write failed, so it is the only
// durable copy of the imported key until the next-boot retry.
assert!(
legacy_path.exists(),
"identity.key must be kept when keyring adoption write fails"
);
// Keyring slot unchanged — write was rejected, no overwrite occurred.
assert_eq!(
store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.map(String::as_str),
Some(keyring_nsec.as_str()),
"keyring slot must be unchanged when adoption write fails"
);
}
// read-only-dir marker-failure injection is Unix-only: on Windows,
// FILE_ATTRIBUTE_READONLY on a directory does not prevent creating new
// files inside it (it only guards the directory entry itself), so the
// marker write succeeds and the fault cannot be injected this way.
#[cfg(unix)]
#[test]
fn present_keyring_with_mismatched_file_adopts_file_key_marker_failure_keeps_file() {
// (b-fault) Present + mismatched identity.key + marker write fails →
// file MUST NOT be deleted so a later keyring-unreachable boot has a
// fallback. Invariant: keyring-only implies marker exists; if marker
// cannot be written, identity.key is the surviving discriminator.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let keyring_keys = Keys::generate();
let keyring_nsec = keyring_keys.secret_key().to_bech32().unwrap();
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
// Force marker write failure by making the data directory read-only.
// AtomicWriteFile writes a temp file in the same dir then renames it,
// so removing write permission on the dir blocks the write entirely.
let dir_perms_orig = std::fs::metadata(dir.path()).unwrap().permissions();
let mut dir_perms_ro = dir_perms_orig.clone();
// unknown_lints: the clippy lint below doesn't exist yet in the pinned
// 1.95 toolchain but does in CI's newer clippy — allow both worlds.
#[allow(unknown_lints)]
#[allow(clippy::permissions_set_readonly_value)]
dir_perms_ro.set_readonly(true);
std::fs::set_permissions(dir.path(), dir_perms_ro).unwrap();
let store = FakeIdentityStore::present_with(&keyring_nsec);
// Resolve with the read-only dir; marker write will fail.
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path());
// Restore perms before any assertions that might panic mid-cleanup.
std::fs::set_permissions(dir.path(), dir_perms_orig).unwrap();
// On a read-only fs the file write also fails; we can't even check
// legacy_path reliably there. What matters is that if resolve succeeded
// it returned the file's key, and did NOT delete the file.
if let Ok(resolved) = resolved {
assert_key_eq(&file_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
// identity.key must NOT have been deleted — it is the only
// fallback when the marker could not be written.
assert!(
legacy_path.exists(),
"identity.key must be kept when marker write fails after adoption"
);
}
// If resolve Err'd (e.g. file write also failed) the test still passes —
// we've verified the code doesn't delete the file without a marker.
}
#[test]
fn reachable_but_empty_with_marker_and_no_file_returns_lost() {
// (d) ReachableButEmpty + marker + no file → "lost" state, NO new key
// generated into the keyring.
//
// The marker says a key was once stored in the keyring. If the keyring
// is now empty (entry deleted externally, new OS login session cleared
// it, etc.) and there is no file fallback, the user's key is truly
// gone. Resolution must NOT silently generate a new identity; it must
// surface a "lost" state so the frontend can prompt re-import.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
// Write the migration marker — a key was once in the keyring.
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists()); // no file fallback
let store = FakeIdentityStore::reachable_but_empty();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The "lost" flag is set — the frontend must prompt re-import.
assert_eq!(
resolved.recovery,
RecoveryState::Lost,
"identity lost state must be surfaced"
);
// No key was persisted to the keyring — the ephemeral key is in-memory
// only and must not overwrite the user's actual (externally lost) key.
assert!(
store.slot.borrow().is_empty(),
"no key must be written to keyring when identity is lost"
);
// No identity.key written either — the ephemeral key is transient.
assert!(!legacy_path.exists());
}
#[test]
fn persist_imported_identity_falls_back_to_file_on_keyring_failure() {
// `persist_imported_identity_impl` with a failing store returns Ok and
// writes identity.key as a fallback. No migration marker is written — a
// marker here would cause fail-closed on a later Unreachable boot even
// though the key is in the file, not the keyring.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let imported_keys = Keys::generate();
let store = FakeIdentityStore::store_failing();
let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path());
// The policy core handles the keyring failure — Ok, not Err.
assert_eq!(result.unwrap(), IdentityStorage::LocalFile);
// Key is recoverable from the file on next boot.
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&imported_keys, &from_file);
// No marker written — the file is the authoritative store, not the keyring.
assert!(!migration_marker_path(dir.path()).exists());
// The underlying kernel still propagates keyring failure (low-level
// contract unchanged — the impl layer is what adds the fallback).
let dir2 = tempfile::tempdir().unwrap();
let path2 = dir2.path().join("identity.key");
assert!(
persist_identity_to_keyring(&store, &imported_keys, &path2, dir2.path()).is_err(),
"persist_identity_to_keyring must still propagate keyring failure"
);
}
#[test]
fn persist_to_keyring_marker_failure_writes_file_when_absent_preserves_invariant() {
// (f) Marker-write failure after a verified keyring write when no
// identity.key exists (e.g. import from a lost state where the file
// was already deleted). The invariant "keyring-only implies marker
// exists" must be preserved: persist_identity_to_keyring must write
// identity.key as a fallback so a later keyring-unreachable boot does
// NOT treat the machine as a fresh install and silently rotate identity.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists()); // no file — simulates import from lost state
let imported_keys = Keys::generate();
// Keyring write succeeds.
let store = FakeIdentityStore::reachable_but_empty();
// Force marker write to fail by placing a directory at the marker path.
// AtomicWriteFile::open fails when the target path is a directory.
let marker_path = migration_marker_path(dir.path());
std::fs::create_dir_all(&marker_path).unwrap();
// persist_identity_to_keyring will: store to keyring (succeeds), read-
// back verify (succeeds), attempt write_migration_marker (fails because
// marker_path is a directory), then write identity.key as a fallback.
let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path());
// The function returns Ok — the error is handled, not propagated.
assert!(
result.is_ok(),
"persist_identity_to_keyring must not propagate marker failure"
);
// identity.key was written as a fallback — invariant preserved.
assert!(
legacy_path.exists(),
"identity.key must exist as fallback when marker write failed and file was absent"
);
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&imported_keys, &from_file);
}
#[test]
fn present_keyring_same_pubkey_file_no_marker_writes_marker_before_cleanup() {
// Present branch: keyring present + same-pubkey identity.key + NO marker.
// This can arise when persist_identity_to_keyring succeeded at keyring
// write + marker write but the remove_file step failed, then the marker
// was deleted externally — or from any earlier code path that stored to
// the keyring without writing the marker.
//
// The fix: write the marker first (crash-safe ordering), then delete the
// file. Must NOT delete the file while no marker exists.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!migration_marker_path(dir.path()).exists()); // no marker
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
// Same key in both keyring and file — stale leftover scenario.
save_key_file(&legacy_path, &keys).unwrap();
let store = FakeIdentityStore::present_with(&nsec);
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// Keyring key is returned.
assert_key_eq(&keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
// Marker must now exist — written before or instead of deleting.
assert!(
migration_marker_path(dir.path()).exists(),
"marker must be written before identity.key is deleted"
);
}
#[test]
fn reachable_but_empty_with_marker_and_no_file_returns_lost_ephemeral_not_persisted() {
// Extension of reachable_but_empty_with_marker_and_no_file_returns_lost:
// also verifies that the ephemeral key returned in lost state is NOT
// persisted to the keyring — it must remain in-memory only.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists());
let store = FakeIdentityStore::reachable_but_empty();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved.recovery, RecoveryState::Lost);
// The ephemeral key must NOT be written to the keyring.
assert!(
store.slot.borrow().is_empty(),
"ephemeral lost key must not be written to keyring"
);
// No identity.key written either.
assert!(!legacy_path.exists());
// The ephemeral pubkey is distinct on every call (sanity check).
let store2 = FakeIdentityStore::reachable_but_empty();
let resolved2 = resolve_identity_with_store(&store2, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved2.recovery, RecoveryState::Lost);
// Two ephemeral keys are different (probabilistic — collision probability is negligible).
assert_ne!(
resolved.keys.public_key().to_hex(),
resolved2.keys.public_key().to_hex(),
"each lost-state boot produces a distinct ephemeral key"
);
}
// ── signing_keys() gate tests ─────────────────────────────────────────────
#[test]
fn signing_keys_returns_ok_when_normal() {
// When neither identity_lost nor keyring_locked is set, signing_keys()
// must return the live keys and allow signing.
let state = build_app_state();
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Relaxed);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::Relaxed);
let result = state.signing_keys();
assert!(
result.is_ok(),
"signing_keys() must return Ok when neither flag is set"
);
// The returned keys must match the stored keys.
let expected = state.keys.lock().unwrap().clone();
assert_key_eq(&result.unwrap(), &expected);
}
#[test]
fn signing_keys_returns_err_when_identity_lost() {
// An ephemeral key is held when identity is lost — signing under it would
// publish events with a random identity the user does not own.
let state = build_app_state();
state
.identity_lost
.store(true, std::sync::atomic::Ordering::Relaxed);
let result = state.signing_keys();
assert!(
result.is_err(),
"signing_keys() must return Err when identity_lost is set"
);
assert!(
result.unwrap_err().contains("recovery mode"),
"error message must mention recovery mode"
);
}
#[test]
fn signing_keys_returns_err_when_keyring_locked() {
// The identity key is held in a keyring that is unavailable this boot —
// the stored keys are inaccessible so signing must be blocked.
let state = build_app_state();
state
.keyring_locked
.store(true, std::sync::atomic::Ordering::Relaxed);
let result = state.signing_keys();
assert!(
result.is_err(),
"signing_keys() must return Err when keyring_locked is set"
);
assert!(
result.unwrap_err().contains("recovery mode"),
"error message must mention recovery mode"
);
}
#[test]
fn signing_keys_identity_lost_takes_priority_over_keyring_locked() {
// When both flags are set, identity_lost is checked first and its error
// message is returned (the ephemeral-key case is more specific).
let state = build_app_state();
state
.identity_lost
.store(true, std::sync::atomic::Ordering::Relaxed);
state
.keyring_locked
.store(true, std::sync::atomic::Ordering::Relaxed);
let err = state.signing_keys().unwrap_err();
assert!(
err.contains("recovery mode"),
"both-set must return recovery-mode error: {err}"
);
}
// ── Keyring-locked recovery mode tests ───────────────────────────────────
#[test]
fn keyring_locked_recovery_ephemeral_never_persisted() {
// Unreachable + marker + no file → KeyringLocked recovery. The ephemeral
// key is held in memory only; no identity.key is created, no keyring
// slot is touched. Fail-closed semantics: no identity is ever rotated.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists());
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved.recovery, RecoveryState::KeyringLocked);
// Nothing written to disk — ephemeral key is transient.
assert!(!legacy_path.exists());
// Keyring was never contacted (it is unreachable).
assert!(store.slot.borrow().is_empty());
assert!(store.deleted.borrow().is_empty());
}
#[test]
fn keyring_locked_recovery_distinct_ephemeral_per_boot() {
// Each locked-state boot produces a distinct ephemeral key and persists
// nothing — mirroring the lost-state guarantee.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists());
let store1 = FakeIdentityStore::unreachable();
let resolved1 = resolve_identity_with_store(&store1, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved1.recovery, RecoveryState::KeyringLocked);
let store2 = FakeIdentityStore::unreachable();
let resolved2 = resolve_identity_with_store(&store2, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved2.recovery, RecoveryState::KeyringLocked);
// Two ephemeral keys are different (probabilistic — collision negligible).
assert_ne!(
resolved1.keys.public_key().to_hex(),
resolved2.keys.public_key().to_hex(),
"each locked-state boot produces a distinct ephemeral key"
);
// Neither boot persisted anything.
assert!(!legacy_path.exists());
}
// ── B1: read-back corruption ──────────────────────────────────────────────
#[test]
fn persist_identity_to_keyring_readback_corrupt_returns_err() {
// B1.1: store() succeeds but load() returns a different valid-format value.
// The read-back verify in persist_identity_to_keyring must detect the
// mismatch and return Err so the caller knows the key was not durably stored.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let other_keys = Keys::generate();
let other_nsec = other_keys.secret_key().to_bech32().unwrap();
let store = FakeIdentityStore::with_readback_corruption(&other_nsec);
let imported_keys = Keys::generate();
let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path());
assert!(
result.is_err(),
"must return Err when read-back returns a different value"
);
assert!(
result.unwrap_err().contains("read-back"),
"error message must mention read-back verify failure"
);
}
#[test]
fn persist_imported_identity_impl_readback_corrupt_falls_back_to_file() {
// B1.2: persist_imported_identity_impl with a readback-corrupt store returns
// Ok and writes identity.key as a fallback, and the file holds the original key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let other_keys = Keys::generate();
let other_nsec = other_keys.secret_key().to_bech32().unwrap();
let store = FakeIdentityStore::with_readback_corruption(&other_nsec);
let imported_keys = Keys::generate();
let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path());
assert!(
result.is_ok(),
"must return Ok when file fallback succeeds after readback corruption: {:?}",
result.err()
);
assert!(
legacy_path.exists(),
"identity.key must be written as fallback"
);
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&imported_keys, &from_file);
}
// ── B2: corrupt key material recovery ────────────────────────────────────
#[test]
fn reachable_but_empty_corrupt_file_generates_fresh() {
// B2.1: ReachableButEmpty probe + corrupt identity.key → migrate_identity_file
// returns Ok(None) for the corrupt file, then generate_and_persist runs and
// stores a fresh valid key in the keyring. No panic; resolve succeeds.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap();
let store = FakeIdentityStore::reachable_but_empty();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved.recovery, RecoveryState::None);
// The keyring now holds the fresh key.
let stored_nsec = store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.cloned()
.expect("keyring must hold a fresh key after corrupt-file recovery");
let keyring_keys = Keys::parse(&stored_nsec).expect("keyring value must be a valid nsec");
assert_key_eq(&resolved.keys, &keyring_keys);
}
#[test]
fn present_corrupt_keyring_and_corrupt_file_generates_fresh() {
// B2.2: Present probe with a corrupt keyring value AND a corrupt identity.key.
// recover_from_keyring clears the bad entry, migrate_identity_file returns
// Ok(None) for the corrupt file, then generate_and_persist stores a fresh key
// in the keyring. Resolve succeeds; keyring holds the fresh valid key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap();
let store = FakeIdentityStore::present_with("not-a-valid-nsec");
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved.recovery, RecoveryState::None);
// The corrupt keyring entry was cleared.
assert!(
store
.deleted
.borrow()
.contains(&IDENTITY_KEY_NAME.to_string()),
"corrupt keyring entry must be cleared"
);
// Keyring holds the newly generated valid key.
let stored_nsec = store
.slot
.borrow()
.get(IDENTITY_KEY_NAME)
.cloned()
.expect("keyring must hold a fresh key after double-corrupt recovery");
let keyring_keys = Keys::parse(&stored_nsec).expect("keyring value must be a valid nsec");
assert_key_eq(&resolved.keys, &keyring_keys);
}
// ── B3: Unreachable probe branches ───────────────────────────────────────
#[test]
fn unreachable_with_valid_file_resolves_to_file_key() {
// B3.a+b (inputs are indistinguishable at this level): Unreachable + valid
// identity.key → resolves to the file's key. The keyring is never contacted
// and the file is kept on disk (no migration when keyring is down).
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_key_eq(&file_keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
assert!(
legacy_path.exists(),
"identity.key must not be deleted when keyring is unreachable"
);
assert!(
store.slot.borrow().is_empty(),
"keyring must not be contacted when unreachable"
);
}
#[test]
fn unreachable_valid_file_with_marker_resolves_to_file_not_locked_recovery() {
// Unreachable + valid identity.key + marker present → resolves to the file
// key, NOT KeyringLocked recovery. The locked-recovery branch only fires
// when the file is ABSENT; a present file is always used as a direct
// fallback regardless of the marker.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let file_keys = Keys::generate();
save_key_file(&legacy_path, &file_keys).unwrap();
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_key_eq(&file_keys, &resolved.keys);
assert_eq!(
resolved.recovery,
RecoveryState::None,
"must not enter locked-recovery when a valid file is present"
);
}
#[test]
fn unreachable_corrupt_file_generates_fresh() {
// B3.c: Unreachable + corrupt identity.key → load_file_or_generate quarantines
// the corrupt file, generates a fresh key, and saves it to identity.key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
std::fs::write(&legacy_path, b"this-is-not-a-valid-nsec").unwrap();
assert!(!migration_marker_path(dir.path()).exists());
let store = FakeIdentityStore::unreachable();
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_eq!(resolved.recovery, RecoveryState::None);
// A fresh key was saved to identity.key (quarantine renames the corrupt file).
assert!(
legacy_path.exists(),
"fresh key must be saved to identity.key"
);
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
}
// ── B4: marker-write failure variants ────────────────────────────────────
#[test]
fn persist_identity_to_keyring_marker_failure_file_fallback_returns_ok() {
// B4.1: marker write fails (data_dir is an existing file, so the marker
// path cannot be created), but the file fallback succeeds — returns Ok and
// identity.key exists and holds the original key.
let dir = tempfile::tempdir().unwrap();
let key_dir = tempfile::tempdir().unwrap();
let legacy_path = key_dir.path().join("identity.key");
assert!(!legacy_path.exists());
// Make data_dir a FILE so marker write fails.
let data_dir_file = dir.path().join("data_as_file");
std::fs::write(&data_dir_file, b"not a dir").unwrap();
let store = FakeIdentityStore::reachable_but_empty();
let imported_keys = Keys::generate();
let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, &data_dir_file);
assert!(
result.is_ok(),
"must return Ok when file fallback succeeds despite marker failure: {:?}",
result.err()
);
assert!(
legacy_path.exists(),
"identity.key must be written as fallback"
);
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&imported_keys, &from_file);
}
#[test]
fn persist_identity_to_keyring_marker_and_file_failure_returns_err() {
// B4.2: both marker write and file write fail → must return Err (A2 fix).
// data_dir is a FILE (marker write fails); legacy_path is in a non-existent
// subdirectory so AtomicWriteFile::open fails on the file write too.
let dir = tempfile::tempdir().unwrap();
let data_dir_file = dir.path().join("data_as_file");
std::fs::write(&data_dir_file, b"not a dir").unwrap();
// Parent directory does not exist → file write fails.
let legacy_path = dir.path().join("nonexistent_subdir").join("identity.key");
assert!(!legacy_path.exists());
let store = FakeIdentityStore::reachable_but_empty();
let imported_keys = Keys::generate();
let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, &data_dir_file);
assert!(
result.is_err(),
"must return Err when both marker write and file write fail"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("persisted") || err_msg.contains("marker") || err_msg.contains("file"),
"error message must describe the dual failure: {err_msg}"
);
}
#[test]
fn present_keyring_no_file_no_marker_self_heals_marker() {
// B4.3 / A3 coverage: Present(valid) + no identity.key + no migration marker.
// After resolve, the marker must exist (self-healed by A3) so a later
// keyring-Unreachable boot does not treat this as a fresh install.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists());
assert!(!migration_marker_path(dir.path()).exists());
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let store = FakeIdentityStore::present_with(&nsec);
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
assert_key_eq(&keys, &resolved.keys);
assert_eq!(resolved.recovery, RecoveryState::None);
assert!(
migration_marker_path(dir.path()).exists(),
"marker must be self-healed by A3 when Present(valid) + no file + no marker"
);
}
// ── I1: uncached read-back verify ─────────────────────────────────────────
#[test]
fn verify_fails_store_does_not_write_marker_or_delete_file() {
// I1: when verify_stored() returns Ok(false) (simulating a backend that
// stores to a cache but does NOT confirm the OS round-trip),
// persist_identity_to_keyring must return Err — the durable state is
// uncertain. The caller must NOT write the migration marker or delete
// identity.key while the durability of the write is unconfirmed.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
let imported_keys = Keys::generate();
save_key_file(&legacy_path, &imported_keys).unwrap();
let store = FakeIdentityStore::with_verify_failing();
let result = persist_identity_to_keyring(&store, &imported_keys, &legacy_path, dir.path());
// Must return Err — durability of the write was not confirmed.
assert!(
result.is_err(),
"persist_identity_to_keyring must return Err when verify_stored returns false"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("read-back"),
"error must mention read-back verify failure: {err_msg}"
);
// No migration marker written — the write was not confirmed durable.
assert!(
!migration_marker_path(dir.path()).exists(),
"migration marker must NOT be written when verify_stored fails"
);
// identity.key must still exist — must not be deleted without confirmation.
assert!(
legacy_path.exists(),
"identity.key must NOT be deleted when verify_stored fails"
);
}
// ── I2: corrupt keyring + marker = Lost recovery ──────────────────────────
#[test]
fn corrupt_keyring_marker_present_no_file_is_lost() {
// I2: Present(corrupt) + migration marker + no identity.key → the prior
// identity was migrated into the keyring and is now unrecoverable (corrupt
// AND no file backup). Must enter Lost recovery, NOT generate a fresh key.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
write_migration_marker(&migration_marker_path(dir.path())).unwrap();
assert!(!legacy_path.exists());
let store = FakeIdentityStore::present_with("not-a-valid-nsec");
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// Must enter Lost recovery — a prior identity existed and is now unrecoverable.
assert_eq!(
resolved.recovery,
RecoveryState::Lost,
"corrupt keyring + marker + no file must return Lost recovery, not a fresh key"
);
// No identity.key written — the ephemeral key is in-memory only.
assert!(!legacy_path.exists());
}
#[test]
fn corrupt_keyring_no_marker_no_file_generates_fresh() {
// I2 (counter-case): Present(corrupt) + NO marker + no identity.key →
// genuine first launch with a corrupt keyring, no prior identity to
// protect. generate_and_persist is still the correct last resort.
let dir = tempfile::tempdir().unwrap();
let legacy_path = dir.path().join("identity.key");
assert!(!legacy_path.exists());
assert!(!migration_marker_path(dir.path()).exists());
let store = FakeIdentityStore::present_with("not-a-valid-nsec");
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// No lost recovery — this is a fresh machine with no prior identity.
assert_eq!(
resolved.recovery,
RecoveryState::None,
"corrupt keyring + no marker + no file must generate a fresh key (no prior identity)"
);
// A fresh, valid key was stored (keyring or file).
assert!(
store.slot.borrow().contains_key(IDENTITY_KEY_NAME) || legacy_path.exists(),
"a fresh key must be stored in the keyring or the file after generate_and_persist"
);
}