Files
khadhroony-solana-project/crates/ksp-wallet-lib/unit_tests/migration.rs
2026-08-22 09:39:13 +02:00

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