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