187 lines
12 KiB
Rust
187 lines
12 KiB
Rust
// file: crates/ksp-wallet-lib/unit_tests/migration.rs
|
|
// version: 1
|
|
|
|
use base64::Engine; // rust-rules: trait-import
|
|
|
|
const OWNER_PASSWORD: &str = "pre005-owner-password";
|
|
const V1_FULL_VECTOR: &[u8] = include_bytes!("../tests/fixtures/kspwallet_v1_full_vector.json");
|
|
const VIEW_PASSWORD: &str = "pre005-view-password";
|
|
|
|
fn runtime() -> tokio::runtime::Runtime {
|
|
return tokio::runtime::Builder::new_current_thread().build().expect("Wallet migration test runtime must build");
|
|
}
|
|
|
|
#[test]
|
|
fn authenticated_v1_to_v2_migration_preserves_identity_metadata_and_note_ids() {
|
|
let runtime = runtime();
|
|
let source_owner = runtime
|
|
.block_on(crate::open_wallet_owner_v1(V1_FULL_VECTOR, crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
|
.expect("V1 fixture OWNER must open before migration");
|
|
let source_pubkey = *source_owner.pubkey();
|
|
let source_alias = source_owner.alias().map(std::string::String::from);
|
|
let source_notes = source_owner.notes().to_vec();
|
|
let migrated = runtime
|
|
.block_on(crate::migrate_wallet_v1_to_v2(
|
|
V1_FULL_VECTOR,
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from("pre017-target-view-password"))),
|
|
))
|
|
.expect("authenticated V1 snapshot must migrate to V2");
|
|
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
|
assert_eq!(*migrated.pubkey(), source_pubkey);
|
|
assert_eq!(migrated.alias(), source_alias.as_deref());
|
|
assert_eq!(migrated.notes(), source_notes.as_slice());
|
|
let bytes = migrated.to_native_bytes().expect("migrated V2 wallet must serialize");
|
|
assert_eq!(crate::detect_wallet_format(bytes.as_slice()).expect("migrated framing must detect"), crate::WalletFormat::V2);
|
|
let reopened_owner = runtime
|
|
.block_on(crate::open_wallet_owner_v2(bytes.as_slice(), crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
|
.expect("migrated V2 OWNER must reuse the authenticated OWNER password");
|
|
assert_eq!(*reopened_owner.pubkey(), source_pubkey);
|
|
assert_eq!(reopened_owner.notes(), source_notes.as_slice());
|
|
let reopened_view = runtime
|
|
.block_on(crate::open_wallet_view_v2(bytes.as_slice(), crate::ViewPassword::new(std::string::String::from("pre017-target-view-password"))))
|
|
.expect("migrated V2 VIEW must use the caller-selected target VIEW password");
|
|
assert_eq!(*reopened_view.pubkey(), source_pubkey);
|
|
assert_eq!(reopened_view.notes(), source_notes.as_slice());
|
|
let old_view = runtime.block_on(crate::open_wallet_view_v2(bytes.as_slice(), crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))));
|
|
assert_eq!(old_view.expect_err("replaced target VIEW credential must reject the historical VIEW password").code(), crate::ERROR_CODE_VIEW_UNLOCK_FAILED);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_requires_target_view_presence_to_match_the_v1_capability_shape() {
|
|
let runtime = runtime();
|
|
let missing_view = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
|
V1_FULL_VECTOR,
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::None,
|
|
));
|
|
assert_eq!(missing_view.expect_err("enabled V1 VIEW requires one target VIEW credential").code(), crate::ERROR_CODE_MIGRATION_INVALID);
|
|
let disabled_owner = runtime
|
|
.block_on(crate::create_wallet_v1(
|
|
crate::OwnerPassword::new(std::string::String::from("pre017-disabled-owner")),
|
|
std::option::Option::None,
|
|
crate::WalletCreateMetadataV1::default(),
|
|
))
|
|
.expect("disabled-VIEW V1 fixture creation must succeed");
|
|
let disabled_bytes = disabled_owner.to_json_bytes().expect("disabled-VIEW V1 fixture must serialize");
|
|
let unexpected_view = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
|
disabled_bytes.as_slice(),
|
|
crate::OwnerPassword::new(std::string::String::from("pre017-disabled-owner")),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from("pre017-unexpected-view"))),
|
|
));
|
|
let error = unexpected_view.expect_err("disabled V1 VIEW must not be silently enabled by pure format migration");
|
|
assert_eq!(error.code(), crate::ERROR_CODE_MIGRATION_INVALID);
|
|
}
|
|
|
|
#[test]
|
|
fn no_clobber_file_migration_preserves_source_and_rejects_existing_destination() {
|
|
let runtime = runtime();
|
|
let directory = tempfile::Builder::new().prefix("ksp-pre017-copy-").tempdir().expect("migration copy directory must be creatable");
|
|
let source = directory.path().join("source-v1.kspwallet");
|
|
let destination = directory.path().join("destination-v2.kspwallet");
|
|
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("V1 migration source must be writable");
|
|
std::fs::write(destination.as_path(), b"existing-destination").expect("migration destination canary must be writable");
|
|
let result = runtime.block_on(crate::migrate_wallet_file_v1_to_v2(
|
|
source.as_path(),
|
|
destination.as_path(),
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
|
));
|
|
assert_eq!(result.expect_err("migration copy must remain no-clobber").code(), crate::ERROR_CODE_DESTINATION_EXISTS);
|
|
assert_eq!(std::fs::read(source.as_path()).expect("V1 source must remain readable"), V1_FULL_VECTOR);
|
|
assert_eq!(std::fs::read(destination.as_path()).expect("existing destination must remain readable"), b"existing-destination");
|
|
std::fs::remove_file(destination.as_path()).expect("migration destination canary must be removable");
|
|
let migrated = runtime
|
|
.block_on(crate::migrate_wallet_file_v1_to_v2(
|
|
source.as_path(),
|
|
destination.as_path(),
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
|
))
|
|
.expect("migration copy must publish a fresh V2 destination");
|
|
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
|
assert_eq!(std::fs::read(source.as_path()).expect("successful migration copy must preserve V1 source"), V1_FULL_VECTOR);
|
|
let destination_bytes = std::fs::read(destination.as_path()).expect("successful migration destination must be readable");
|
|
assert_eq!(crate::detect_wallet_format(destination_bytes.as_slice()).expect("migration destination framing must detect"), crate::WalletFormat::V2);
|
|
}
|
|
|
|
#[test]
|
|
fn in_place_file_migration_atomically_replaces_current_v1_with_v2() {
|
|
let runtime = runtime();
|
|
let directory = tempfile::Builder::new().prefix("ksp-pre017-in-place-").tempdir().expect("migration in-place directory must be creatable");
|
|
let source = directory.path().join("wallet.kspwallet");
|
|
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("V1 in-place migration source must be writable");
|
|
let migrated = runtime
|
|
.block_on(crate::migrate_wallet_file_v1_to_v2_in_place(
|
|
source.as_path(),
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
|
))
|
|
.expect("current authenticated V1 source must migrate in place");
|
|
assert_eq!(migrated.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
|
let bytes = std::fs::read(source.as_path()).expect("migrated in-place file must remain readable");
|
|
assert_eq!(crate::detect_wallet_format(bytes.as_slice()).expect("migrated file framing must detect"), crate::WalletFormat::V2);
|
|
let owner = runtime
|
|
.block_on(crate::open_wallet_owner_file(source.as_path(), crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD))))
|
|
.expect("generic OWNER file open must read migrated V2");
|
|
let view = runtime
|
|
.block_on(crate::open_wallet_view_file(source.as_path(), crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))))
|
|
.expect("generic VIEW file open must read migrated V2");
|
|
assert_eq!(owner.notes(), view.notes());
|
|
assert_eq!(owner.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
|
assert_eq!(view.format_version(), crate::KSPWALLET_FORMAT_VERSION_V2);
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_v1_source_is_rejected_before_migration_publication() {
|
|
let runtime = runtime();
|
|
let mut value: serde_json::Value = serde_json::from_slice(V1_FULL_VECTOR).expect("migration tamper fixture must parse as JSON");
|
|
let target = value.pointer_mut("/metadata/ciphertext").expect("migration tamper fixture metadata pointer must exist");
|
|
let encoded = target.as_str().expect("migration tamper target must be Base64url text");
|
|
let mut decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded.as_bytes()).expect("migration tamper target must decode");
|
|
let first = decoded.first_mut().expect("migration tamper target must not be empty");
|
|
*first ^= 0x01;
|
|
*target = serde_json::Value::String(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(decoded.as_slice()));
|
|
zeroize::Zeroize::zeroize(decoded.as_mut_slice());
|
|
let tampered = serde_json::to_vec(&value).expect("migration tamper fixture must serialize");
|
|
let result = runtime.block_on(crate::migrate_wallet_v1_to_v2(
|
|
tampered.as_slice(),
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
|
));
|
|
assert_eq!(result.expect_err("tampered OWNER-authenticated V1 state must not migrate").code(), crate::ERROR_CODE_AUTHENTICATION_FAILED);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_in_place_source_is_rejected_without_overwriting_the_newer_v1_state() {
|
|
let runtime = runtime();
|
|
let directory = tempfile::Builder::new().prefix("ksp-pre017-stale-").tempdir().expect("migration stale directory must be creatable");
|
|
let source = directory.path().join("wallet.kspwallet");
|
|
std::fs::write(source.as_path(), V1_FULL_VECTOR).expect("stale migration source must be writable");
|
|
let replacement_owner = runtime
|
|
.block_on(crate::create_wallet_v1(
|
|
crate::OwnerPassword::new(std::string::String::from("pre017-newer-owner")),
|
|
std::option::Option::None,
|
|
crate::WalletCreateMetadataV1::new(std::option::Option::Some(std::string::String::from("newer-state")), std::vec::Vec::new()),
|
|
))
|
|
.expect("newer V1 state fixture must be creatable");
|
|
let replacement_bytes = replacement_owner.to_json_bytes().expect("newer V1 state fixture must serialize");
|
|
let hook_source = source.clone();
|
|
let hook_bytes = replacement_bytes.clone();
|
|
let result = runtime.block_on(super::migrate_wallet_file_v1_to_v2_in_place_with_hook(
|
|
source.clone(),
|
|
crate::OwnerPassword::new(std::string::String::from(OWNER_PASSWORD)),
|
|
std::option::Option::Some(crate::ViewPassword::new(std::string::String::from(VIEW_PASSWORD))),
|
|
move || {
|
|
return match std::fs::write(hook_source.as_path(), hook_bytes.as_slice()) {
|
|
std::result::Result::Ok(()) => std::result::Result::Ok(()),
|
|
std::result::Result::Err(error) => std::result::Result::Err(
|
|
ksp_core_lib::Error::new(crate::ERROR_CODE_IO_FAILED, "Migration stale-state test hook could not replace source").with_source(error),
|
|
),
|
|
};
|
|
},
|
|
));
|
|
assert_eq!(result.expect_err("stale in-place migration must abort").code(), crate::ERROR_CODE_STATE_CONFLICT);
|
|
assert_eq!(std::fs::read(source.as_path()).expect("newer V1 state must remain readable"), replacement_bytes);
|
|
}
|