tests.rs (85643B)
1 use std::{collections::BTreeMap, sync::Arc}; 2 3 use axum::{ 4 Json, Router, 5 body::{Body, to_bytes}, 6 extract::{Query, State}, 7 http::{HeaderMap, Method, Request, StatusCode, header}, 8 middleware, 9 routing::{get, post}, 10 }; 11 use tokio::sync::Mutex; 12 use tower::ServiceExt; 13 14 use crate::{ 15 adapters::{ 16 chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork, 17 ui_data_store::SqliteUiDataStore, wallet_store, 18 }, 19 app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, 20 domain::{ 21 Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, 22 GenesisBurn, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, 23 OutPoint, RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, 24 Wallet, 25 }, 26 }; 27 28 use super::{ 29 AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters, 30 WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form, 31 api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed, 32 hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, 33 persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn, 34 same_origin_request, validate_password, validate_transfer_form, verify_password, 35 wallet_transaction_rows, wallet_utxo_rows, 36 }; 37 38 #[test] 39 fn dev_seed_verify_bypass_requires_env_flag() { 40 assert!(dev_seed_verify_bypass_allowed(true)); 41 assert!(!dev_seed_verify_bypass_allowed(false)); 42 } 43 44 #[test] 45 fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() { 46 let commitment = "a".repeat(64); 47 let owner = "c".repeat(64); 48 let input_outpoint = OutPoint { 49 txid: "d".repeat(64), 50 index: 0, 51 }; 52 let blinded = BlindedTransaction { 53 commitment: commitment.clone(), 54 inputs: vec![TxInput { 55 outpoint: input_outpoint.clone(), 56 owner: owner.clone(), 57 signature: "e".repeat(128), 58 }], 59 fee: 7, 60 encrypted_size: 123, 61 expires_at_height: 42, 62 nonce: "00".repeat(12), 63 ciphertext: "11".repeat(123), 64 payload_hash: "b".repeat(64), 65 }; 66 let reveal = BlindedReveal { 67 commitment: commitment.clone(), 68 key: "22".repeat(32), 69 }; 70 let outputs = BTreeMap::from([( 71 input_outpoint.clone(), 72 TxOutput { 73 address: owner.clone(), 74 amount: 99, 75 }, 76 )]); 77 78 let blinded_row = super::ui_blinded_transaction(&blinded, &outputs); 79 let reveal_row = super::ui_blinded_reveal(&reveal); 80 let revealed_row = super::ui_pending_revealed_transaction( 81 &crate::domain::RevealedBlindedTransaction { 82 height: 2, 83 commitment: reveal.commitment.clone(), 84 included_by: owner.clone(), 85 transaction: Transaction::Burn { 86 inputs: blinded.inputs.clone(), 87 change: Vec::new(), 88 amount: 12, 89 fee: blinded.fee, 90 signature: "f".repeat(128), 91 }, 92 }, 93 &outputs, 94 ); 95 96 assert_eq!(blinded_row.kind, "blinded"); 97 assert_eq!(blinded_row.from, owner); 98 assert_eq!(blinded_row.inputs.len(), 1); 99 assert_eq!(blinded_row.inputs[0].amount, Some(99)); 100 assert_eq!(blinded_row.signature, commitment); 101 assert_eq!(blinded_row.encrypted_size, Some(123)); 102 assert_eq!(blinded_row.expires_at_height, Some(42)); 103 assert_eq!(reveal_row.kind, "reveal"); 104 assert_eq!(reveal_row.commitment, blinded_row.commitment); 105 assert_eq!(revealed_row.kind, "burn"); 106 assert!(revealed_row.revealed); 107 assert_eq!(revealed_row.commitment, Some(reveal.commitment)); 108 assert_eq!(revealed_row.amount, 12); 109 assert_eq!(revealed_row.fee, 7); 110 assert_eq!(revealed_row.inputs[0].amount, Some(99)); 111 } 112 113 #[test] 114 fn block_detail_transactions_include_blinded_and_revealed_items() { 115 let alice = Wallet::from_seed("block-detail-blind-alice"); 116 let bob = Wallet::from_seed("block-detail-blind-bob"); 117 let mut allocations = BTreeMap::new(); 118 allocations.insert(alice.address().to_string(), 100); 119 allocations.insert(bob.address().to_string(), 100); 120 let ledger = Ledger::new(allocations.clone(), 1); 121 let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); 122 let built = ledger 123 .build_blinded_transaction(&alice, transfer.clone(), 20) 124 .unwrap(); 125 let mut block = fake_block(7, Vec::new()); 126 block.blinded_transactions = vec![built.transaction.clone()]; 127 128 let revealed = crate::domain::RevealedBlindedTransaction { 129 height: 7, 130 commitment: built.transaction.commitment.clone(), 131 included_by: "miner".to_string(), 132 transaction: transfer, 133 }; 134 let ui_block = super::ui_block(block, &BTreeMap::new(), &BTreeMap::new(), &[revealed]); 135 136 assert_eq!(ui_block.transactions.len(), 2); 137 assert_eq!(ui_block.transactions[0].kind, "blinded"); 138 assert_eq!( 139 ui_block.transactions[0].commitment.as_deref(), 140 Some(built.transaction.commitment.as_str()) 141 ); 142 assert!(!ui_block.transactions[0].revealed); 143 assert_eq!(ui_block.transactions[1].kind, "transfer"); 144 assert!(ui_block.transactions[1].revealed); 145 assert_eq!(ui_block.revealed_transactions.len(), 1); 146 assert!(ui_block.revealed_transactions[0].revealed); 147 assert!(ui_block.total_bytes > 0); 148 assert!(ui_block.blinded_transaction_bytes > 0); 149 assert_eq!(ui_block.reveal_bundle_bytes, 0); 150 151 let burn = ledger.build_burn(&alice, 5, 1).unwrap(); 152 let mine = Transaction::Mine { 153 recipient: bob.address().to_string(), 154 anchor: "a".repeat(64), 155 salt: 1, 156 nonce: 2, 157 difficulty_bits: 12, 158 proof_header: None, 159 signature: "b".repeat(64), 160 }; 161 let typed_block = super::ui_block( 162 fake_block( 163 8, 164 vec![ 165 ledger.build_transfer(&alice, bob.address(), 7, 1).unwrap(), 166 burn, 167 mine, 168 ], 169 ), 170 &BTreeMap::new(), 171 &BTreeMap::new(), 172 &[], 173 ); 174 let typed_bytes = typed_block 175 .transaction_byte_breakdown 176 .iter() 177 .map(|row| (row.label, row.bytes)) 178 .collect::<BTreeMap<_, _>>(); 179 assert!(typed_bytes["transfer"] > 0); 180 assert!(typed_bytes["burn"] > 0); 181 assert!(typed_bytes["mine"] > 0); 182 } 183 184 #[test] 185 fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { 186 let alice = Wallet::from_seed("block-detail-reveal-alice"); 187 let bob = Wallet::from_seed("block-detail-reveal-bob"); 188 let mut allocations = BTreeMap::new(); 189 allocations.insert(alice.address().to_string(), 100); 190 allocations.insert(bob.address().to_string(), 100); 191 let ledger = Ledger::new(allocations.clone(), 1); 192 let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); 193 let built = ledger 194 .build_blinded_transaction(&alice, transfer.clone(), 20) 195 .unwrap(); 196 let mut commit_block = fake_block(7, Vec::new()); 197 commit_block.blinded_transactions = vec![built.transaction.clone()]; 198 let mut reveal_block = fake_block(8, Vec::new()); 199 reveal_block.transactions = vec![Transaction::Mine { 200 recipient: bob.address().to_string(), 201 anchor: "a".repeat(64), 202 salt: 1, 203 nonce: 2, 204 difficulty_bits: 12, 205 proof_header: None, 206 signature: "b".repeat(64), 207 }]; 208 reveal_block.reveal_bundle_section = RevealBundleSection { 209 signatures: vec![RevealBundleSignature { 210 slot: 0, 211 member: reveal_block.miner.clone(), 212 signature: "11".repeat(64), 213 }], 214 reveals: vec![MaskedBlindedReveal { 215 reveal: built.reveal.clone(), 216 bundle_mask: 1, 217 }], 218 }; 219 let snapshot = fake_snapshot( 220 allocations, 221 vec![commit_block.clone(), reveal_block.clone()], 222 ); 223 let mut burn_leader_ranks = BTreeMap::new(); 224 burn_leader_ranks.insert( 225 reveal_block.hash.clone(), 226 vec![ 227 BurnLeaderRank { 228 rank: 0, 229 ticket_id: "ticket-0".to_string(), 230 owner: alice.address().to_string(), 231 amount: 1, 232 eligible_from_height: 8, 233 eligible_until_height: 8, 234 }, 235 BurnLeaderRank { 236 rank: 1, 237 ticket_id: "ticket-1".to_string(), 238 owner: bob.address().to_string(), 239 amount: 1, 240 eligible_from_height: 8, 241 eligible_until_height: 8, 242 }, 243 BurnLeaderRank { 244 rank: 2, 245 ticket_id: "ticket-2".to_string(), 246 owner: "carol".to_string(), 247 amount: 1, 248 eligible_from_height: 8, 249 eligible_until_height: 8, 250 }, 251 ], 252 ); 253 254 let blocks = super::ui_blocks( 255 vec![commit_block, reveal_block], 256 &snapshot, 257 &[], 258 &burn_leader_ranks, 259 ); 260 261 assert_eq!(blocks[0].transactions.len(), 1); 262 assert_eq!(blocks[0].transactions[0].kind, "blinded"); 263 assert_eq!( 264 blocks[0].transactions[0].commitment.as_deref(), 265 Some(built.transaction.commitment.as_str()) 266 ); 267 assert_eq!(blocks[1].transactions.len(), 2); 268 assert_eq!(blocks[1].transactions[0].kind, "mine"); 269 assert_eq!(blocks[1].transactions[1].kind, "transfer"); 270 assert!(blocks[1].transactions[1].revealed); 271 assert_eq!(blocks[1].transactions[1].amount, transfer.amount()); 272 assert_eq!(blocks[1].transactions[1].to.as_deref(), Some(bob.address())); 273 assert_eq!(blocks[1].revealed_transactions.len(), 1); 274 assert_eq!(blocks[1].reveal_fee_penalty.reveal_lists_included, 1); 275 assert_eq!(blocks[1].reveal_fee_penalty.committee_size, 3); 276 assert_eq!(blocks[1].total_fees, transfer.fee() + MINE_FINALIZER_FEE); 277 assert_eq!( 278 blocks[1].reveal_fee_penalty.fee_penalty, 279 ((transfer.fee() + MINE_FINALIZER_FEE) as u128 * 2 / 3) as u64 280 ); 281 } 282 283 #[test] 284 fn block_detail_markup_uses_blinded_and_revealed_labels() { 285 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 286 287 assert!(super::INDEX_HTML.contains("txPillLabel(tx)")); 288 assert!(super::INDEX_HTML.contains("commitCountLabel(block)")); 289 assert!(super::INDEX_HTML.contains(".pill.blinded")); 290 assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed")); 291 assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\"")); 292 assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block")); 293 assert!(super::INDEX_HTML.contains(":class=\"row[2]\"")); 294 assert!(super::INDEX_HTML.contains("New since last block")); 295 assert!(super::INDEX_HTML.contains("class=\"mempool-top\"")); 296 assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)")); 297 assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">")); 298 assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">")); 299 assert!(super::INDEX_HTML.contains("fee-penalty-value")); 300 assert!( 301 super::INDEX_HTML 302 .contains(":class=\"{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }\"") 303 ); 304 assert!(super::INDEX_HTML.contains("Commitment")); 305 assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>")); 306 assert!(app_js.contains("tx?.revealed ? \"revealed\"")); 307 assert!(app_js.contains("transactions.some((tx) => tx?.revealed)")); 308 assert!(app_js.contains("blockCommitCount(block)")); 309 assert!(app_js.contains("blockTransactionByteBreakdown(block)")); 310 assert!(app_js.contains("[label, Number(row.bytes ?? 0), label]")); 311 } 312 313 #[test] 314 fn password_policy_rejects_short_or_excessive_passwords() { 315 let short = validate_password("too-short").unwrap_err(); 316 assert!(short.to_string().contains("at least 12")); 317 318 let long_password = "x".repeat(1025); 319 let long = validate_password(&long_password).unwrap_err(); 320 assert!(long.to_string().contains("too long")); 321 322 validate_password("correct horse battery staple").unwrap(); 323 } 324 325 #[test] 326 fn password_hash_round_trips_without_storing_plaintext() { 327 let password = "correct horse battery staple"; 328 let encoded = hash_password(password).unwrap(); 329 330 assert!(!encoded.contains(password)); 331 assert!(verify_password(password, &encoded).unwrap()); 332 assert!(!verify_password("wrong horse battery staple", &encoded).unwrap()); 333 } 334 335 #[test] 336 fn pbkdf2_sha256_matches_known_vectors() { 337 let one_iteration = pbkdf2_sha256(b"password", b"salt", 1); 338 assert_eq!( 339 hex_encode(one_iteration), 340 "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" 341 ); 342 343 let two_iterations = pbkdf2_sha256(b"password", b"salt", 2); 344 assert_eq!( 345 hex_encode(two_iterations), 346 "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43" 347 ); 348 } 349 350 #[test] 351 fn same_origin_check_accepts_forwarded_host_and_rejects_cross_site_origin() { 352 let mut headers = HeaderMap::new(); 353 headers.insert(header::HOST, "127.0.0.1:18661".parse().unwrap()); 354 headers.insert("x-forwarded-host", "iuna.example".parse().unwrap()); 355 headers.insert(header::ORIGIN, "https://iuna.example".parse().unwrap()); 356 assert!(same_origin_request(&headers)); 357 358 headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); 359 assert!(!same_origin_request(&headers)); 360 } 361 362 #[test] 363 fn auth_client_key_trusts_forwarded_headers_only_from_private_or_local_peers() { 364 let mut headers = HeaderMap::new(); 365 headers.insert("x-forwarded-for", "198.51.100.99".parse().unwrap()); 366 let socket = Some("203.0.113.10:51234".parse().unwrap()); 367 368 assert_eq!(auth_client_key(&headers, socket), "203.0.113.10"); 369 assert_eq!( 370 auth_client_key(&headers, Some("127.0.0.1:51234".parse().unwrap())), 371 "198.51.100.99" 372 ); 373 assert_eq!( 374 auth_client_key(&headers, Some("10.42.1.12:51234".parse().unwrap())), 375 "198.51.100.99" 376 ); 377 assert_eq!( 378 auth_client_key(&headers, Some("172.20.4.8:51234".parse().unwrap())), 379 "198.51.100.99" 380 ); 381 assert_eq!(auth_client_key(&headers, None), "198.51.100.99"); 382 } 383 384 #[tokio::test] 385 async fn protected_endpoints_require_authentication_setup() { 386 let dir = tempfile::tempdir().unwrap(); 387 let state = auth_test_state( 388 dir.path().join("config.json"), 389 UiConfig { 390 auth_password_hash: None, 391 ..UiConfig::default() 392 }, 393 ) 394 .await; 395 let app = auth_test_app(state); 396 397 let protected = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; 398 assert_eq!(protected.status, StatusCode::UNAUTHORIZED); 399 assert!(protected.body.contains("authentication setup is required")); 400 401 let status = http_request(app, Method::GET, "/api/auth/status", None, "").await; 402 assert_eq!(status.status, StatusCode::OK); 403 assert!(status.body.contains("\"configured\":false")); 404 } 405 406 #[tokio::test] 407 async fn favicon_is_public_before_authentication_setup() { 408 let dir = tempfile::tempdir().unwrap(); 409 let state = auth_test_state( 410 dir.path().join("config.json"), 411 UiConfig { 412 auth_password_hash: None, 413 ..UiConfig::default() 414 }, 415 ) 416 .await; 417 let app = auth_test_app(state); 418 419 let response = http_request(app, Method::GET, "/favicon.ico", None, "").await; 420 421 assert_eq!(response.status, StatusCode::NO_CONTENT); 422 } 423 424 #[tokio::test] 425 async fn protected_endpoints_require_valid_session_after_authentication_setup() { 426 let dir = tempfile::tempdir().unwrap(); 427 let password = "correct horse battery staple"; 428 let state = auth_test_state( 429 dir.path().join("config.json"), 430 UiConfig { 431 auth_password_hash: Some(hash_password(password).unwrap()), 432 ..UiConfig::default() 433 }, 434 ) 435 .await; 436 let app = auth_test_app(state); 437 438 let missing_cookie = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; 439 assert_eq!(missing_cookie.status, StatusCode::UNAUTHORIZED); 440 assert!(missing_cookie.body.contains("authentication required")); 441 442 let bad_cookie = http_request( 443 app.clone(), 444 Method::GET, 445 "/api/protected", 446 Some("iuna_session=bogus"), 447 "", 448 ) 449 .await; 450 assert_eq!(bad_cookie.status, StatusCode::UNAUTHORIZED); 451 452 let login = http_request( 453 app.clone(), 454 Method::POST, 455 "/api/auth/login", 456 None, 457 "password=correct+horse+battery+staple", 458 ) 459 .await; 460 assert_eq!(login.status, StatusCode::OK); 461 assert!(login.body.contains("\"ok\":true")); 462 let cookie = set_cookie_pair(&login.headers); 463 assert!(cookie.starts_with(AUTH_COOKIE_NAME)); 464 465 let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; 466 assert_eq!(protected.status, StatusCode::OK); 467 assert_eq!(protected.body, "protected"); 468 } 469 470 #[tokio::test] 471 async fn auth_posts_require_same_origin_headers() { 472 let dir = tempfile::tempdir().unwrap(); 473 let password = "correct horse battery staple"; 474 let state = auth_test_state( 475 dir.path().join("config.json"), 476 UiConfig { 477 auth_password_hash: Some(hash_password(password).unwrap()), 478 ..UiConfig::default() 479 }, 480 ) 481 .await; 482 let app = auth_test_app(state); 483 484 let response = app 485 .oneshot( 486 Request::builder() 487 .method(Method::POST) 488 .uri("/api/auth/login") 489 .header(header::HOST, "127.0.0.1:18661") 490 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") 491 .body(Body::from("password=correct+horse+battery+staple")) 492 .unwrap(), 493 ) 494 .await 495 .unwrap(); 496 assert_eq!(response.status(), StatusCode::FORBIDDEN); 497 let body = String::from_utf8( 498 to_bytes(response.into_body(), usize::MAX) 499 .await 500 .unwrap() 501 .to_vec(), 502 ) 503 .unwrap(); 504 assert!(body.contains("same-origin request required")); 505 } 506 507 #[tokio::test] 508 async fn login_authentication_locks_out_after_repeated_failures() { 509 let dir = tempfile::tempdir().unwrap(); 510 let password = "correct horse battery staple"; 511 let state = auth_test_state( 512 dir.path().join("config.json"), 513 UiConfig { 514 auth_password_hash: Some(hash_password(password).unwrap()), 515 ..UiConfig::default() 516 }, 517 ) 518 .await; 519 let client_a = "198.51.100.10"; 520 let client_b = "198.51.100.11"; 521 522 for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { 523 let error = super::login_auth_password(&state, "wrong horse battery staple", client_a) 524 .await 525 .unwrap_err(); 526 assert!(format!("{error:#}").contains("invalid password")); 527 } 528 529 let locked = super::login_auth_password(&state, password, client_a) 530 .await 531 .unwrap_err(); 532 assert!(format!("{locked:#}").contains("too many failed login attempts")); 533 534 let other_client_cookie = super::login_auth_password(&state, password, client_b) 535 .await 536 .unwrap(); 537 assert!(other_client_cookie.starts_with(AUTH_COOKIE_NAME)); 538 539 state 540 .auth_backoff 541 .lock() 542 .await 543 .get_mut(client_a) 544 .unwrap() 545 .locked_until_ms = Some(crate::app::now_ms().saturating_sub(1)); 546 let cookie = super::login_auth_password(&state, password, client_a) 547 .await 548 .unwrap(); 549 assert!(cookie.starts_with(AUTH_COOKIE_NAME)); 550 assert!(!state.auth_backoff.lock().await.contains_key(client_a)); 551 } 552 553 #[tokio::test] 554 async fn password_setup_creates_session_for_protected_endpoints() { 555 let dir = tempfile::tempdir().unwrap(); 556 let config_path = dir.path().join("config.json"); 557 let state = auth_test_state(config_path.clone(), UiConfig::default()).await; 558 let app = auth_test_app(state); 559 560 let setup = http_request( 561 app.clone(), 562 Method::POST, 563 "/api/auth/setup", 564 None, 565 "password=correct+horse+battery+staple", 566 ) 567 .await; 568 assert_eq!(setup.status, StatusCode::OK); 569 assert!(setup.body.contains("\"ok\":true")); 570 let cookie = set_cookie_pair(&setup.headers); 571 572 let stored = config_store::load_or_create(&config_path).unwrap(); 573 assert!(stored.auth_password_hash.is_some()); 574 let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; 575 assert_eq!(protected.status, StatusCode::OK); 576 assert_eq!(protected.body, "protected"); 577 } 578 579 #[tokio::test] 580 async fn password_change_reencrypts_wallet_and_replaces_login_password() { 581 let dir = tempfile::tempdir().unwrap(); 582 let config_path = dir.path().join("config.json"); 583 let wallet_path = config_path.with_file_name("wallet.json"); 584 let old_password = "correct horse battery staple"; 585 let new_password = "new correct battery staple"; 586 let state = auth_test_state( 587 config_path.clone(), 588 UiConfig { 589 auth_password_hash: Some(hash_password(old_password).unwrap()), 590 setup_complete: true, 591 ..UiConfig::default() 592 }, 593 ) 594 .await; 595 wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap(); 596 let app = auth_test_app(state); 597 598 let login = http_request( 599 app.clone(), 600 Method::POST, 601 "/api/auth/login", 602 None, 603 "password=correct+horse+battery+staple", 604 ) 605 .await; 606 assert_eq!(login.status, StatusCode::OK); 607 let cookie = set_cookie_pair(&login.headers); 608 609 let change = http_request( 610 app.clone(), 611 Method::POST, 612 "/api/auth/change-password", 613 Some(&cookie), 614 "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple", 615 ) 616 .await; 617 assert_eq!(change.status, StatusCode::OK); 618 assert!(change.body.contains("\"ok\":true")); 619 620 let stored = config_store::load_or_create(&config_path).unwrap(); 621 let stored_hash = stored.auth_password_hash.unwrap(); 622 assert!(!verify_password(old_password, &stored_hash).unwrap()); 623 assert!(verify_password(new_password, &stored_hash).unwrap()); 624 assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err()); 625 assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok()); 626 627 let old_login = http_request( 628 app.clone(), 629 Method::POST, 630 "/api/auth/login", 631 None, 632 "password=correct+horse+battery+staple", 633 ) 634 .await; 635 assert!(old_login.body.contains("invalid password")); 636 637 let new_login = http_request( 638 app, 639 Method::POST, 640 "/api/auth/login", 641 None, 642 "password=new+correct+battery+staple", 643 ) 644 .await; 645 assert_eq!(new_login.status, StatusCode::OK); 646 } 647 648 #[tokio::test] 649 async fn peer_management_updates_config_file() { 650 let dir = tempfile::tempdir().unwrap(); 651 let config_path = dir.path().join("config.json"); 652 let state = auth_test_state( 653 config_path.clone(), 654 UiConfig { 655 setup_complete: true, 656 ..UiConfig::default() 657 }, 658 ) 659 .await; 660 661 super::add_peer(&state, " 127.0.0.1:9445 ".to_string()) 662 .await 663 .unwrap(); 664 let config = config_store::load_or_create(&config_path).unwrap(); 665 assert_eq!(config.peers, vec!["127.0.0.1:9445"]); 666 assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]); 667 668 super::remove_peer(&state, "127.0.0.1:9445".to_string()) 669 .await 670 .unwrap(); 671 let config = config_store::load_or_create(&config_path).unwrap(); 672 assert!(config.peers.is_empty()); 673 assert!(state.peers.lock().await.addresses().is_empty()); 674 } 675 676 #[tokio::test] 677 async fn address_book_updates_config_file() { 678 let dir = tempfile::tempdir().unwrap(); 679 let config_path = dir.path().join("config.json"); 680 let state = auth_test_state( 681 config_path.clone(), 682 UiConfig { 683 setup_complete: true, 684 ..UiConfig::default() 685 }, 686 ) 687 .await; 688 let alice = Wallet::from_seed("address-book-alice"); 689 let bob = Wallet::from_seed("address-book-bob"); 690 691 super::upsert_address_book_entry( 692 &state, 693 format!(" {} ", alice.address().to_ascii_uppercase()), 694 " Alice ".to_string(), 695 None, 696 ) 697 .await 698 .unwrap(); 699 let config = config_store::load_or_create(&config_path).unwrap(); 700 assert_eq!( 701 config.address_book.get(alice.address()), 702 Some(&"Alice".to_string()) 703 ); 704 705 super::upsert_address_book_entry( 706 &state, 707 alice.address().to_string(), 708 "Alice Prime".to_string(), 709 Some(alice.address().to_string()), 710 ) 711 .await 712 .unwrap(); 713 let config = config_store::load_or_create(&config_path).unwrap(); 714 assert_eq!( 715 config.address_book.get(alice.address()), 716 Some(&"Alice Prime".to_string()) 717 ); 718 719 let error = super::upsert_address_book_entry( 720 &state, 721 alice.address().to_string(), 722 "Alice Duplicate".to_string(), 723 None, 724 ) 725 .await 726 .unwrap_err(); 727 assert!(format!("{error:#}").contains("address is already saved")); 728 729 let carol = Wallet::from_seed("address-book-carol"); 730 super::upsert_address_book_entry( 731 &state, 732 carol.address().to_string(), 733 "Carol".to_string(), 734 None, 735 ) 736 .await 737 .unwrap(); 738 let error = super::upsert_address_book_entry( 739 &state, 740 carol.address().to_string(), 741 "Bob As Carol".to_string(), 742 Some(alice.address().to_string()), 743 ) 744 .await 745 .unwrap_err(); 746 assert!(format!("{error:#}").contains("address is already saved")); 747 748 super::upsert_address_book_entry( 749 &state, 750 bob.address().to_string(), 751 "Bob".to_string(), 752 Some(alice.address().to_string()), 753 ) 754 .await 755 .unwrap(); 756 let config = config_store::load_or_create(&config_path).unwrap(); 757 assert!(!config.address_book.contains_key(alice.address())); 758 assert_eq!( 759 config.address_book.get(bob.address()), 760 Some(&"Bob".to_string()) 761 ); 762 assert_eq!( 763 config.address_book.get(carol.address()), 764 Some(&"Carol".to_string()) 765 ); 766 767 let error = super::upsert_address_book_entry( 768 &state, 769 "iuna-address".to_string(), 770 "Not Alice".to_string(), 771 None, 772 ) 773 .await 774 .unwrap_err(); 775 assert!(format!("{error:#}").contains("invalid address book address")); 776 777 super::remove_address_book_entry(&state, bob.address().to_string()) 778 .await 779 .unwrap(); 780 let config = config_store::load_or_create(&config_path).unwrap(); 781 assert!(!config.address_book.contains_key(bob.address())); 782 assert!(config.address_book.contains_key(carol.address())); 783 } 784 785 #[tokio::test] 786 async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { 787 let dir = tempfile::tempdir().unwrap(); 788 let config_path = dir.path().join("config.json"); 789 let state = auth_test_state( 790 config_path.clone(), 791 UiConfig { 792 setup_complete: true, 793 ..UiConfig::default() 794 }, 795 ) 796 .await; 797 798 super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string()) 799 .await 800 .unwrap(); 801 802 let config = config_store::load_or_create(&config_path).unwrap(); 803 assert_eq!( 804 config.p2p_announce_addr.as_deref(), 805 Some("203.0.113.10:9444") 806 ); 807 match state.gossip.peer_exchange().await { 808 GossipEnvelope::PeerList { peers } => { 809 assert!(!peers.contains(&"203.0.113.10:9444".to_string())); 810 } 811 other => panic!("expected peer list, got {other:?}"), 812 } 813 814 super::set_p2p_accept_inbound(&state, true, Some(9555)) 815 .await 816 .unwrap(); 817 let config = config_store::load_or_create(&config_path).unwrap(); 818 assert!(config.p2p_accept_inbound); 819 assert_eq!(config.p2p_bind_port, 9555); 820 assert!(!state.gossip.accepts_inbound().await); 821 match state.gossip.peer_exchange().await { 822 GossipEnvelope::PeerList { peers } => { 823 assert!(!peers.contains(&"203.0.113.10:9444".to_string())); 824 } 825 other => panic!("expected peer list, got {other:?}"), 826 } 827 828 super::set_p2p_accept_inbound(&state, false, None) 829 .await 830 .unwrap(); 831 let config = config_store::load_or_create(&config_path).unwrap(); 832 assert!(!config.p2p_accept_inbound); 833 assert!(!state.gossip.accepts_inbound().await); 834 835 super::set_p2p_announce_addr(&state, " ".to_string()) 836 .await 837 .unwrap(); 838 let config = config_store::load_or_create(&config_path).unwrap(); 839 assert!(config.p2p_announce_addr.is_none()); 840 } 841 842 #[tokio::test] 843 async fn p2p_announce_setting_rejects_invalid_address() { 844 let dir = tempfile::tempdir().unwrap(); 845 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 846 847 let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string()) 848 .await 849 .unwrap_err(); 850 851 assert!(format!("{error:#}").contains("invalid P2P announce address")); 852 } 853 854 #[tokio::test] 855 async fn setup_config_form_can_add_bootstrap_peer() { 856 let dir = tempfile::tempdir().unwrap(); 857 let config_path = dir.path().join("config.json"); 858 let state = auth_test_state(config_path.clone(), UiConfig::default()).await; 859 860 super::apply_config_form( 861 &state, 862 super::ConfigForm { 863 setup_complete: true, 864 peer: " iuna.jhx.app:9444 ".to_string(), 865 }, 866 ) 867 .await 868 .unwrap(); 869 870 let config = config_store::load_or_create(&config_path).unwrap(); 871 assert!(config.setup_complete); 872 assert_eq!(config.peers, vec!["iuna.jhx.app:9444"]); 873 assert_eq!( 874 state.peers.lock().await.addresses(), 875 vec!["iuna.jhx.app:9444"] 876 ); 877 } 878 879 #[tokio::test] 880 async fn setup_config_form_requires_peer_for_placeholder_chain() { 881 let dir = tempfile::tempdir().unwrap(); 882 let config_path = dir.path().join("config.json"); 883 let state = auth_test_state(config_path.clone(), UiConfig::default()).await; 884 885 let error = super::apply_config_form( 886 &state, 887 super::ConfigForm { 888 setup_complete: true, 889 peer: " ".to_string(), 890 }, 891 ) 892 .await 893 .unwrap_err(); 894 895 assert!(format!("{error:#}").contains("add a bootstrap peer")); 896 let config = config_store::load_or_create(&config_path).unwrap(); 897 assert!(!config.setup_complete); 898 } 899 900 #[tokio::test] 901 async fn peer_management_rejects_empty_and_inbound_removal() { 902 let dir = tempfile::tempdir().unwrap(); 903 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 904 905 assert!(super::add_peer(&state, " ".to_string()).await.is_err()); 906 state 907 .peers 908 .lock() 909 .await 910 .record_received("127.0.0.1:9555", 1); 911 912 let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await; 913 assert!(result.is_err()); 914 assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); 915 } 916 917 #[tokio::test] 918 async fn network_health_summarizes_sync_and_peer_errors() { 919 let dir = tempfile::tempdir().unwrap(); 920 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 921 let status = state.node.lock().await.status(); 922 let local = super::NetworkHealthLocalState { 923 height: status.chain.height, 924 pending_transactions: status.chain.pending_transactions, 925 }; 926 let mempool = super::MempoolCounts { 927 plain_transactions: 1, 928 blinded_transactions: 2, 929 blinded_reveals: 3, 930 }; 931 932 let isolated = super::network_health(local, &[], mempool); 933 assert!(!isolated.ok); 934 assert_eq!(isolated.state, "isolated"); 935 assert_eq!(isolated.local_height, 0); 936 assert_eq!(isolated.best_known_height, 0); 937 assert_eq!(isolated.pending_plain_transactions, 1); 938 assert_eq!(isolated.pending_blinded_transactions, 2); 939 assert_eq!(isolated.pending_blinded_reveals, 3); 940 941 let mut clock_peers = PeerBook::from_addresses(vec![ 942 "127.0.0.1:9450".to_string(), 943 "127.0.0.1:9451".to_string(), 944 ]); 945 clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string()); 946 clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string()); 947 clock_peers.record_clock_observation("127.0.0.1:9450", PeerDirection::Outbound, 10_500, 10_000); 948 clock_peers.record_clock_observation( 949 "127.0.0.1:9451", 950 PeerDirection::Outbound, 951 11 * 60 * 1_000, 952 10_000, 953 ); 954 let clock_health = super::network_health_at(local, &clock_peers.list(), mempool, 10_000); 955 assert_eq!(clock_health.network_time_offset_ms, Some(500)); 956 assert_eq!(clock_health.bad_clock_peers, 1); 957 958 let syncing = super::network_health( 959 local, 960 &[PeerInfo { 961 address: "127.0.0.1:9445".to_string(), 962 direction: PeerDirection::Outbound, 963 messages_sent: 1, 964 messages_received: 1, 965 last_known_height: Some(3), 966 last_known_tip_hash: Some("remote-tip".to_string()), 967 last_clock_offset_ms: None, 968 last_clock_offset_accepted: None, 969 last_clock_observed_ms: None, 970 last_error: None, 971 last_contact_ms: Some(10_000), 972 last_success_ms: Some(10_000), 973 last_error_ms: None, 974 misbehavior_score: 0, 975 banned_until_ms: None, 976 ban_reason: None, 977 }], 978 mempool, 979 ); 980 assert!(!syncing.ok); 981 assert_eq!(syncing.state, "syncing"); 982 assert_eq!(syncing.best_known_height, 3); 983 assert_eq!(syncing.lag_blocks, 3); 984 985 let peer_errors = super::network_health( 986 local, 987 &[PeerInfo { 988 address: "127.0.0.1:9446".to_string(), 989 direction: PeerDirection::Outbound, 990 messages_sent: 0, 991 messages_received: 0, 992 last_known_height: None, 993 last_known_tip_hash: None, 994 last_clock_offset_ms: None, 995 last_clock_offset_accepted: None, 996 last_clock_observed_ms: None, 997 last_error: Some("connection refused".to_string()), 998 last_contact_ms: Some(10_000), 999 last_success_ms: None, 1000 last_error_ms: Some(10_000), 1001 misbehavior_score: 1, 1002 banned_until_ms: None, 1003 ban_reason: Some("connection refused".to_string()), 1004 }], 1005 mempool, 1006 ); 1007 assert!(!peer_errors.ok); 1008 assert_eq!(peer_errors.state, "peer errors"); 1009 assert_eq!( 1010 peer_errors.last_error.as_deref(), 1011 Some("127.0.0.1:9446: connection refused") 1012 ); 1013 1014 let stale = super::network_health_at( 1015 local, 1016 &[PeerInfo { 1017 address: "127.0.0.1:9447".to_string(), 1018 direction: PeerDirection::Outbound, 1019 messages_sent: 1, 1020 messages_received: 1, 1021 last_known_height: Some(0), 1022 last_known_tip_hash: Some("tip".to_string()), 1023 last_clock_offset_ms: None, 1024 last_clock_offset_accepted: None, 1025 last_clock_observed_ms: None, 1026 last_error: None, 1027 last_contact_ms: Some(1), 1028 last_success_ms: Some(1), 1029 last_error_ms: None, 1030 misbehavior_score: 0, 1031 banned_until_ms: None, 1032 ban_reason: None, 1033 }], 1034 mempool, 1035 PEER_STALE_AFTER_MS + 2, 1036 ); 1037 assert!(!stale.ok); 1038 assert_eq!(stale.state, "stale"); 1039 assert_eq!(stale.stale_peers, 1); 1040 1041 let banned = super::network_health_at( 1042 local, 1043 &[PeerInfo { 1044 address: "127.0.0.1:9448".to_string(), 1045 direction: PeerDirection::Outbound, 1046 messages_sent: 0, 1047 messages_received: 0, 1048 last_known_height: None, 1049 last_known_tip_hash: None, 1050 last_clock_offset_ms: None, 1051 last_clock_offset_accepted: None, 1052 last_clock_observed_ms: None, 1053 last_error: Some("invalid block".to_string()), 1054 last_contact_ms: Some(10), 1055 last_success_ms: None, 1056 last_error_ms: Some(10), 1057 misbehavior_score: 3, 1058 banned_until_ms: Some(1_000), 1059 ban_reason: Some("invalid block".to_string()), 1060 }], 1061 mempool, 1062 20, 1063 ); 1064 assert!(!banned.ok); 1065 assert_eq!(banned.state, "banned"); 1066 assert_eq!(banned.banned_peers, 1); 1067 } 1068 1069 #[test] 1070 fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { 1071 let alice = Wallet::from_seed("wallet-history-alice"); 1072 let bob = Wallet::from_seed("wallet-history-bob"); 1073 let carol = Wallet::from_seed("wallet-history-carol"); 1074 let mut allocations = BTreeMap::new(); 1075 allocations.insert(alice.address().to_string(), 100); 1076 allocations.insert(bob.address().to_string(), 100); 1077 allocations.insert(carol.address().to_string(), 100); 1078 let ledger = crate::domain::Ledger::new(allocations.clone(), 1); 1079 let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); 1080 let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); 1081 let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); 1082 let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap(); 1083 let chain = vec![ 1084 fake_block(30, vec![carol_transfer]), 1085 fake_block(31, vec![old_received.clone()]), 1086 fake_block(32, vec![carol_burn]), 1087 ]; 1088 1089 let snapshot = fake_snapshot(allocations, chain.clone()); 1090 let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn)); 1091 let rows = wallet_transaction_rows( 1092 alice.address(), 1093 vec![pending_burn.clone()], 1094 Vec::new(), 1095 &chain, 1096 &BTreeMap::new(), 1097 &outputs, 1098 WalletTransactionFilters::default(), 1099 ); 1100 1101 assert_eq!(rows.len(), 1); 1102 assert_ne!(rows[0].signature, pending_burn.signature()); 1103 assert_eq!(rows[0].signature, old_received.signature()); 1104 assert_eq!(rows[0].inputs[0].amount, Some(100)); 1105 assert_eq!(rows[0].status, "confirmed"); 1106 assert_eq!(rows[0].block_height, Some(31)); 1107 assert_eq!(rows[0].timestamp_ms, Some(31)); 1108 assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); 1109 assert_eq!(rows[0].direction, "received"); 1110 } 1111 1112 #[test] 1113 fn wallet_transactions_show_owned_blinded_payloads_as_pending_blind() { 1114 let alice = Wallet::from_seed("wallet-blind-pending-alice"); 1115 let bob = Wallet::from_seed("wallet-blind-pending-bob"); 1116 let mut allocations = BTreeMap::new(); 1117 allocations.insert(alice.address().to_string(), 100); 1118 allocations.insert(bob.address().to_string(), 100); 1119 let ledger = Ledger::new(allocations.clone(), 1); 1120 let pending_blind = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); 1121 let snapshot = fake_snapshot(allocations, Vec::new()); 1122 let outputs = super::known_output_index(&snapshot, &[]); 1123 1124 let rows = wallet_transaction_rows( 1125 alice.address(), 1126 Vec::new(), 1127 vec![pending_blind.clone()], 1128 &[], 1129 &BTreeMap::new(), 1130 &outputs, 1131 WalletTransactionFilters::default(), 1132 ); 1133 1134 assert_eq!(rows.len(), 1); 1135 assert_eq!(rows[0].kind, "transfer"); 1136 assert_eq!(rows[0].status, "pending"); 1137 assert_eq!(rows[0].timestamp_ms, None); 1138 assert!(rows[0].blinded); 1139 assert_eq!(rows[0].direction, "sent"); 1140 assert_eq!(rows[0].to.as_deref(), Some(bob.address())); 1141 assert_eq!(rows[0].amount, 12); 1142 assert_eq!(rows[0].fee, 3); 1143 assert_eq!(rows[0].signature, pending_blind.signature()); 1144 } 1145 1146 #[test] 1147 fn wallet_transaction_query_defaults_to_tx_only() { 1148 assert_eq!( 1149 WalletTransactionFilters::from_query(WalletTransactionsQuery::default()), 1150 WalletTransactionFilters::default() 1151 ); 1152 assert_eq!( 1153 WalletTransactionFilters::from_query(WalletTransactionsQuery { 1154 tx: Some(false), 1155 mine: Some(true), 1156 burn: Some(true), 1157 offset: None, 1158 limit: None, 1159 }), 1160 WalletTransactionFilters { 1161 transfer: false, 1162 mine: true, 1163 burn: true, 1164 } 1165 ); 1166 } 1167 1168 #[test] 1169 fn page_items_returns_bounded_slices_with_next_offset() { 1170 let page = super::page_items( 1171 vec![1, 2, 3, 4, 5], 1172 super::PageQuery { 1173 offset: Some(1), 1174 limit: Some(2), 1175 }, 1176 ); 1177 1178 assert_eq!(page.items, vec![2, 3]); 1179 assert_eq!(page.offset, 1); 1180 assert_eq!(page.limit, 2); 1181 assert_eq!(page.total, 5); 1182 assert!(page.has_more); 1183 assert_eq!(page.next_offset, Some(3)); 1184 } 1185 1186 #[test] 1187 fn page_items_clamps_limit_and_empty_tail() { 1188 let page = super::page_items( 1189 vec![1, 2], 1190 super::PageQuery { 1191 offset: Some(20), 1192 limit: Some(0), 1193 }, 1194 ); 1195 1196 assert!(page.items.is_empty()); 1197 assert_eq!(page.offset, 2); 1198 assert_eq!(page.limit, 1); 1199 assert_eq!(page.total, 2); 1200 assert!(!page.has_more); 1201 assert_eq!(page.next_offset, None); 1202 } 1203 1204 #[test] 1205 fn mine_transaction_views_include_protocol_finalizer_fee() { 1206 let alice = Wallet::from_seed("wallet-mine-fee-alice"); 1207 let ledger = Ledger::new(BTreeMap::new(), 1); 1208 let mine = ledger.build_mine(alice.address()).unwrap(); 1209 let chain = vec![fake_block(1, vec![mine.clone()])]; 1210 let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); 1211 let outputs = super::known_output_index(&snapshot, &[]); 1212 1213 let rows = wallet_transaction_rows( 1214 alice.address(), 1215 Vec::new(), 1216 Vec::new(), 1217 &chain, 1218 &BTreeMap::new(), 1219 &outputs, 1220 WalletTransactionFilters { 1221 transfer: false, 1222 mine: true, 1223 burn: false, 1224 }, 1225 ); 1226 let transaction = super::ui_transaction(&mine, &outputs); 1227 1228 assert_eq!(rows.len(), 1); 1229 assert_eq!(rows[0].amount, mine.amount()); 1230 assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); 1231 assert_eq!(transaction.amount, mine.amount()); 1232 assert_eq!(transaction.fee, MINE_FINALIZER_FEE); 1233 } 1234 1235 #[test] 1236 fn wallet_transactions_include_public_mine_actions() { 1237 let alice = Wallet::from_seed("wallet-revealed-mine-alice"); 1238 let ledger = Ledger::new( 1239 BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]), 1240 1, 1241 ); 1242 let mine = ledger.build_mine(alice.address()).unwrap(); 1243 let mut mine_block = fake_block(8, vec![mine.clone()]); 1244 mine_block.reward = mine.fee(); 1245 let chain = vec![mine_block.clone()]; 1246 let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); 1247 let revealed_by_height = super::revealed_transactions_by_height(&snapshot); 1248 let outputs = super::known_output_index(&snapshot, &[]); 1249 1250 let rows = wallet_transaction_rows( 1251 alice.address(), 1252 Vec::new(), 1253 Vec::new(), 1254 &chain, 1255 &revealed_by_height, 1256 &outputs, 1257 WalletTransactionFilters { 1258 transfer: false, 1259 mine: true, 1260 burn: false, 1261 }, 1262 ); 1263 1264 assert_eq!(rows.len(), 1); 1265 assert_eq!(rows[0].kind, "mine"); 1266 assert_eq!(rows[0].status, "confirmed"); 1267 assert_eq!(rows[0].block_height, Some(8)); 1268 assert_eq!(rows[0].timestamp_ms, Some(8)); 1269 assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); 1270 assert_eq!(rows[0].direction, "received"); 1271 assert_eq!(rows[0].amount, mine.amount()); 1272 assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); 1273 assert!(!rows[0].blinded); 1274 assert_eq!(rows[0].signature, mine.signature()); 1275 } 1276 1277 #[test] 1278 fn burn_wallet_transactions_require_burn_filter() { 1279 let alice = Wallet::from_seed("wallet-burn-filter-alice"); 1280 let mut allocations = BTreeMap::new(); 1281 allocations.insert(alice.address().to_string(), 10); 1282 let ledger = Ledger::new(allocations.clone(), 1); 1283 let burn = ledger.build_burn(&alice, 3, 1).unwrap(); 1284 let snapshot = fake_snapshot(allocations, Vec::new()); 1285 let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn)); 1286 1287 let default_rows = wallet_transaction_rows( 1288 alice.address(), 1289 vec![burn.clone()], 1290 Vec::new(), 1291 &[], 1292 &BTreeMap::new(), 1293 &outputs, 1294 WalletTransactionFilters::default(), 1295 ); 1296 let burn_rows = wallet_transaction_rows( 1297 alice.address(), 1298 vec![burn.clone()], 1299 Vec::new(), 1300 &[], 1301 &BTreeMap::new(), 1302 &outputs, 1303 WalletTransactionFilters { 1304 transfer: false, 1305 mine: false, 1306 burn: true, 1307 }, 1308 ); 1309 1310 assert!(default_rows.is_empty()); 1311 assert_eq!(burn_rows.len(), 1); 1312 assert_eq!(burn_rows[0].kind, "burn"); 1313 assert_eq!(burn_rows[0].direction, "burned"); 1314 assert_eq!(burn_rows[0].amount, 3); 1315 assert_eq!(burn_rows[0].fee, 1); 1316 } 1317 1318 #[test] 1319 fn wallet_utxo_rows_include_pending_spent_outputs_as_disabled() { 1320 let alice = Wallet::from_seed("wallet-utxo-pending-alice"); 1321 let bob = Wallet::from_seed("wallet-utxo-pending-bob"); 1322 let mut allocations = BTreeMap::new(); 1323 allocations.insert(alice.address().to_string(), 10); 1324 let mut ledger = Ledger::new(allocations, 1); 1325 let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); 1326 let Transaction::Transfer { inputs, .. } = &pending else { 1327 panic!("expected transfer"); 1328 }; 1329 let spent_outpoint = inputs[0].outpoint.clone(); 1330 1331 ledger.submit_transaction(pending).unwrap(); 1332 let rows = wallet_utxo_rows(&ledger, alice.address()); 1333 1334 assert!(rows.iter().any(|row| row.outpoint == spent_outpoint)); 1335 assert!( 1336 rows.iter() 1337 .any(|row| row.outpoint == spent_outpoint && !row.spendable) 1338 ); 1339 } 1340 1341 #[test] 1342 fn selectable_wallet_utxo_rows_include_only_spendable_outputs() { 1343 let alice = Wallet::from_seed("wallet-utxo-selectable-alice"); 1344 let bob = Wallet::from_seed("wallet-utxo-selectable-bob"); 1345 let mut allocations = BTreeMap::new(); 1346 allocations.insert(alice.address().to_string(), 10); 1347 let mut ledger = Ledger::new(allocations, 1); 1348 let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); 1349 let Transaction::Transfer { inputs, .. } = &pending else { 1350 panic!("expected transfer"); 1351 }; 1352 let spent_outpoint = inputs[0].outpoint.clone(); 1353 1354 ledger.submit_transaction(pending).unwrap(); 1355 let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address()); 1356 1357 assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint)); 1358 assert!(rows.iter().all(|row| row.spendable)); 1359 } 1360 1361 #[test] 1362 fn wallet_utxo_rows_treat_owned_blinded_spends_as_pending() { 1363 let alice = Wallet::from_seed("wallet-utxo-blind-pending-alice"); 1364 let bob = Wallet::from_seed("wallet-utxo-blind-pending-bob"); 1365 let mut allocations = BTreeMap::new(); 1366 allocations.insert(alice.address().to_string(), 10); 1367 let ledger = Ledger::new(allocations, 1); 1368 let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); 1369 let pending = node.transfer_with_fee(bob.address(), 3, 0).unwrap(); 1370 let Transaction::Transfer { inputs, .. } = &pending else { 1371 panic!("expected transfer"); 1372 }; 1373 let spent_outpoint = inputs[0].outpoint.clone(); 1374 let wallet_view = node.wallet_view_ledger().unwrap(); 1375 1376 let rows = wallet_utxo_rows(&wallet_view, alice.address()); 1377 let selectable = super::selectable_wallet_utxo_rows(&wallet_view, alice.address()); 1378 1379 assert!( 1380 rows.iter() 1381 .any(|row| row.outpoint == spent_outpoint && !row.spendable) 1382 ); 1383 assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint)); 1384 } 1385 1386 #[test] 1387 fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() { 1388 let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice"); 1389 let mut allocations = BTreeMap::new(); 1390 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 1391 let ledger = Ledger::new_with_genesis_burns( 1392 allocations, 1393 vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], 1394 1, 1395 ) 1396 .unwrap(); 1397 let mut node = 1398 NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0); 1399 1400 let plan = node.prepare_automatic_finalization(1); 1401 assert!(plan.burned.is_some()); 1402 let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address()); 1403 1404 assert!(!rows.is_empty()); 1405 assert!(rows.iter().any(|row| !row.spendable)); 1406 } 1407 1408 fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { 1409 Block { 1410 height, 1411 prev_hash: format!("prev-{height}"), 1412 timestamp_ms: height, 1413 miner: "miner".to_string(), 1414 finalizer_mode: crate::domain::FinalizerMode::Ticket, 1415 finalizer_rank: 0, 1416 reward: 100, 1417 vdf_rounds: 0, 1418 vdf_output: "vdf".to_string(), 1419 leader_proof: None, 1420 blinded_transactions: Vec::new(), 1421 reveal_bundle_section: RevealBundleSection::default(), 1422 transactions, 1423 hash: format!("hash-{height}"), 1424 } 1425 } 1426 1427 fn fake_snapshot( 1428 genesis_allocations: BTreeMap<String, Amount>, 1429 blocks: Vec<Block>, 1430 ) -> ChainSnapshot { 1431 ChainSnapshot { 1432 genesis_allocations, 1433 vdf_rounds: 1, 1434 launch_profile: LaunchProfile::default(), 1435 blocks, 1436 } 1437 } 1438 1439 fn metric_row( 1440 height: u64, 1441 block_time_ms: Option<u64>, 1442 vdf_rounds: u64, 1443 ) -> crate::adapters::ui_data_store::BlockMetricRow { 1444 crate::adapters::ui_data_store::BlockMetricRow { 1445 height, 1446 block_hash: format!("hash-{height}"), 1447 timestamp_ms: height, 1448 block_time_ms, 1449 mine_difficulty_bits: 12, 1450 circulating_supply: 100, 1451 known_wallet_addresses: 1, 1452 transaction_count: 0, 1453 transfer_count: 0, 1454 burn_count: 0, 1455 mine_count: 0, 1456 burned_amount: 0, 1457 total_burned_amount: 0, 1458 fees_amount: 0, 1459 reward_amount: 0, 1460 vdf_rounds, 1461 finalizer_rank: 0, 1462 } 1463 } 1464 1465 #[test] 1466 fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { 1467 let response = super::metrics_response( 1468 true, 1469 vec![ 1470 metric_row(0, None, 0), 1471 metric_row(1, Some(1_764_000_000_000), 0), 1472 metric_row(2, Some(600_000), 120), 1473 metric_row(3, Some(610_000), 130), 1474 ], 1475 super::types::MetricsLeaderboards::default(), 1476 ); 1477 1478 let block_time = response 1479 .charts 1480 .iter() 1481 .find(|chart| chart.id == "block-time") 1482 .expect("block time chart should exist"); 1483 assert_eq!( 1484 block_time 1485 .points 1486 .iter() 1487 .map(|point| (point.height, point.value)) 1488 .collect::<Vec<_>>(), 1489 vec![(2, 600.0), (3, 610.0)] 1490 ); 1491 1492 let vdf_rounds = response 1493 .charts 1494 .iter() 1495 .find(|chart| chart.id == "vdf-rounds") 1496 .expect("VDF rounds chart should exist"); 1497 assert_eq!( 1498 vdf_rounds 1499 .points 1500 .iter() 1501 .map(|point| (point.height, point.value)) 1502 .collect::<Vec<_>>(), 1503 vec![(2, 120.0), (3, 130.0)] 1504 ); 1505 1506 let known_wallet_addresses = response 1507 .charts 1508 .iter() 1509 .find(|chart| chart.id == "known-wallet-addresses") 1510 .expect("known wallet addresses chart should exist"); 1511 assert_eq!( 1512 known_wallet_addresses 1513 .points 1514 .iter() 1515 .map(|point| (point.height, point.value)) 1516 .collect::<Vec<_>>(), 1517 vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)] 1518 ); 1519 } 1520 1521 #[test] 1522 fn metrics_screen_includes_block_range_filter() { 1523 assert!(super::INDEX_HTML.contains("iuna-ui.js?v=103")); 1524 assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); 1525 assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); 1526 assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); 1527 assert!(super::INDEX_HTML.contains("setMetricsRange('all')")); 1528 assert!(super::INDEX_HTML.contains("Known addresses")); 1529 assert!(super::INDEX_HTML.contains("knownWalletAddresses")); 1530 assert!(super::INDEX_HTML.contains("metric-chart-hover-point")); 1531 assert!(!super::INDEX_HTML.contains("metric-chart-point-hit")); 1532 assert!(!super::INDEX_HTML.contains("metric-chart-hover-point\" x-show")); 1533 assert!(super::INDEX_HTML.contains("<template x-if=\"metricHover?.chartId === chart.id\">")); 1534 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1535 assert!(app_js.contains("shellRefreshPromise: null")); 1536 assert!(app_js.contains("metricsRequestSeq: 0")); 1537 assert!(app_js.contains("await this.refreshMetrics(options);")); 1538 assert!(app_js.contains("this.refreshShellState({ addressBookVersion, silent: true });")); 1539 assert!(app_js.contains("async refreshShellState(options = {})")); 1540 assert!(app_js.contains("this.refreshMetrics();")); 1541 assert!(!app_js.contains("this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] };\n this.refresh({ force: true });")); 1542 assert!(app_js.contains("async fetchMetricsResponse(range = this.metricsRange)")); 1543 assert!(app_js.contains("prepareMetricsResponse(metrics)")); 1544 assert!(app_js.contains("_linePoints: linePoints")); 1545 assert!(app_js.contains("_gridPath: gridPath")); 1546 assert!(!app_js.contains("label: this.metricPointLabel(chart, point)")); 1547 assert!(app_js.contains("label: this.metricPointLabel(chart, marker)")); 1548 assert!(app_js.contains("metricHoverPointStyle(chart)")); 1549 } 1550 1551 #[test] 1552 fn version_update_check_normalizes_tags_before_comparing() { 1553 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1554 let update_available = app_js 1555 .split("updateAvailable()") 1556 .nth(1) 1557 .expect("updateAvailable should exist") 1558 .split("versionPanelTitle()") 1559 .next() 1560 .expect("updateAvailable should precede versionPanelTitle"); 1561 1562 assert!( 1563 update_available 1564 .contains("const current = this.normalizeVersion(this.status.app_version);") 1565 ); 1566 assert!( 1567 update_available.contains("const latest = this.normalizeVersion(this.latestRelease?.tag);") 1568 ); 1569 assert!(update_available.contains("if (current === latest) return false;")); 1570 } 1571 1572 #[test] 1573 fn blocks_endpoint_uses_cached_block_projection_without_cloning_full_ui_index() { 1574 let api_rs = include_str!("api.rs"); 1575 assert!(api_rs.contains("cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks)")); 1576 assert!(!api_rs.contains("let mut outputs = view.outputs;\n add_pending_outputs(&mut outputs, &pending);\n Json(ui_blocks_from_indexes(\n blocks,\n &outputs,")); 1577 } 1578 1579 #[test] 1580 fn mempool_and_wallet_transaction_endpoints_use_narrow_output_lookups() { 1581 let api_rs = include_str!("api.rs"); 1582 assert!(api_rs.contains("load_outputs_for_outpoints(&state, required_outputs)")); 1583 assert!(api_rs.contains("load_outputs_for_outpoints(&state, pending_required_outputs)")); 1584 assert!(api_rs.contains("load_outputs_for_outpoints(&state, confirmed_required_outputs)")); 1585 assert!(!api_rs.contains("let mut outputs = view.outputs;")); 1586 } 1587 1588 #[test] 1589 fn http_server_logs_slow_api_requests() { 1590 let http_rs = include_str!("../http.rs"); 1591 assert!(http_rs.contains("const SLOW_UI_REQUEST_LOG_MS: u128 = 250;")); 1592 assert!(http_rs.contains("async fn log_slow_api_request")); 1593 assert!(http_rs.contains("slow UI API request: {method} {path}")); 1594 } 1595 1596 #[test] 1597 fn fee_estimate_polling_is_tab_scoped() { 1598 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1599 let refresh = app_js 1600 .split("async refreshFeeEstimates()") 1601 .nth(1) 1602 .expect("refreshFeeEstimates should exist") 1603 .split("async refreshBurnFeeEstimate()") 1604 .next() 1605 .expect("refreshFeeEstimates body should precede burn fee estimate"); 1606 1607 assert!(refresh.contains("if (this.tab === \"wallet\")")); 1608 assert!(refresh.contains("await this.refreshTransferFeeEstimate();")); 1609 assert!(refresh.contains("if (this.tab === \"mining\")")); 1610 assert!(refresh.contains("this.refreshBurnFeeEstimate()")); 1611 assert!(refresh.contains("this.refreshMineFeeEstimate()")); 1612 } 1613 1614 #[test] 1615 fn transfer_utxo_selection_supports_shift_click_ranges() { 1616 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1617 assert!( 1618 super::INDEX_HTML 1619 .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\"") 1620 ); 1621 assert!(super::INDEX_HTML.contains(".send-utxo-option {")); 1622 assert!(super::INDEX_HTML.contains("user-select: none;")); 1623 assert!(super::INDEX_HTML.contains(".send-utxo-option input {")); 1624 assert!(super::INDEX_HTML.contains("pointer-events: none;")); 1625 assert!( 1626 !super::INDEX_HTML 1627 .contains("x-model=\"selectedTransferUtxos\" @click=\"toggleTransferUtxoSelection") 1628 ); 1629 assert!(!super::INDEX_HTML.contains("@change=\"toggleTransferUtxoSelection($event, utxo)\"")); 1630 assert!( 1631 super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"") 1632 ); 1633 assert!( 1634 !super::INDEX_HTML 1635 .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=") 1636 ); 1637 assert!(app_js.contains("lastSelectedTransferUtxo: null")); 1638 assert!(app_js.contains("toggleTransferUtxoSelection(event, utxo)")); 1639 assert!(app_js.contains("const checked = !selected.has(outpoint);")); 1640 assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0")); 1641 assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort")); 1642 } 1643 1644 #[test] 1645 fn reveal_mempool_items_show_unknown_fee_label() { 1646 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1647 assert!(app_js.contains("txFeeLabel(tx)")); 1648 assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\"")); 1649 assert!(app_js.contains("unknown until reveal")); 1650 assert!( 1651 app_js.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")") 1652 ); 1653 assert!(app_js.contains("mempoolFirstSeenHeights")); 1654 assert!(app_js.contains("mempoolFirstSeenAt")); 1655 assert!( 1656 app_js.contains("this.trackMempoolFirstSeenHeights({ append: options.replace !== true })") 1657 ); 1658 assert!(app_js.contains("return rightSeenAt - leftSeenAt")); 1659 assert!(app_js.contains("syncMempoolBlockMarker")); 1660 assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight")); 1661 assert!(app_js.contains("mempoolItemClass")); 1662 assert!(app_js.contains("walletTxTimeLabel(tx)")); 1663 assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms")); 1664 assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\"")); 1665 assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\"")); 1666 assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>")); 1667 assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\"")); 1668 } 1669 1670 #[test] 1671 fn wallet_screen_includes_address_book_alias_controls() { 1672 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1673 assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); 1674 assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); 1675 assert!(super::INDEX_HTML.contains("addressBookEntries()")); 1676 assert!(super::INDEX_HTML.contains("openAddressBookModal()")); 1677 assert!(super::INDEX_HTML.contains("addressBookModalOpen")); 1678 assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); 1679 assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); 1680 assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); 1681 assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); 1682 assert!( 1683 super::INDEX_HTML 1684 .contains("x-show=\"!addressBookModalOpen\" @click=\"openAddressBookModal()\"") 1685 ); 1686 assert!( 1687 super::INDEX_HTML.contains("class=\"address-book-form\" x-show=\"addressBookModalOpen\"") 1688 ); 1689 assert!( 1690 super::INDEX_HTML.contains("removeAddressBookEntry({ address: addressBookEditingAddress") 1691 ); 1692 assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); 1693 assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); 1694 assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); 1695 assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); 1696 assert!(app_js.contains("addressBook: {}")); 1697 assert!(app_js.contains("addressBookVersion: 0")); 1698 assert!(app_js.contains("addressBookPickerOpen: false")); 1699 assert!(app_js.contains("this.config.address_book || this.config.addressBook")); 1700 assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); 1701 assert!(app_js.contains("async saveAddressBookEntry()")); 1702 assert!(app_js.contains("Address is already saved")); 1703 assert!(app_js.contains("validAddressBookAddress(address)")); 1704 assert!(app_js.contains("openAddressBookPicker()")); 1705 assert!(app_js.contains("this.addressBookPickerOpen = true;")); 1706 assert!(!app_js.contains("No contacts saved yet")); 1707 assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); 1708 assert!(app_js.contains("\"/api/address-book\"")); 1709 assert!(app_js.contains("addressLabel(address)")); 1710 assert!(app_js.contains("shortAddressLabel(address)")); 1711 } 1712 1713 #[test] 1714 fn initial_setup_includes_node_mode_choices() { 1715 assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); 1716 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); 1717 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')")); 1718 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')")); 1719 assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'")); 1720 assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\"")); 1721 assert!(super::INDEX_HTML.contains("Change later in Settings")); 1722 } 1723 1724 #[test] 1725 fn setup_completion_refreshes_chain_data() { 1726 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1727 assert!( 1728 app_js.contains("await this.refresh({ force: true });\n this.setupFeedback = null;") 1729 ); 1730 assert!( 1731 !app_js.contains( 1732 "await this.refreshConfig();\n await this.resetPagedDataset(\"peer\");" 1733 ) 1734 ); 1735 } 1736 1737 #[test] 1738 fn setup_completion_applies_selected_node_mode() { 1739 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1740 assert!(app_js.contains("setupNodeMode: \"wallet\"")); 1741 assert!(app_js.contains("async applySetupNodeMode()")); 1742 assert!(app_js.contains("await this.applySetupNodeMode();")); 1743 assert!(app_js.contains("\"/api/settings/p2p-inbound\"")); 1744 assert!(app_js.contains("bind_port: this.p2pBindPortValue()")); 1745 assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")")); 1746 } 1747 1748 #[test] 1749 fn p2p_bind_port_changes_show_global_restart_notice() { 1750 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1751 assert!(super::INDEX_HTML.contains("Bind port")); 1752 assert!(super::INDEX_HTML.contains("persistent-banner")); 1753 assert!(super::INDEX_HTML.contains("p2pRestartRequired()")); 1754 assert!(app_js.contains("p2pBindPort: 9444")); 1755 assert!(app_js.contains("p2pConfiguredBindAddr()")); 1756 assert!(app_js.contains("p2pRestartMessage()")); 1757 assert!(app_js.contains("Restart iuna to close the public P2P listener.")); 1758 assert!(app_js.contains("0.0.0.0:${port}")); 1759 } 1760 1761 #[test] 1762 fn settings_includes_dangerous_chain_reset_flow() { 1763 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1764 assert!(super::INDEX_HTML.contains("Danger Zone")); 1765 assert!(super::INDEX_HTML.contains("Delete local chain")); 1766 assert!(super::INDEX_HTML.contains("Type <strong>RESET</strong> to confirm.")); 1767 assert!(super::INDEX_HTML.contains("class=\"danger\"")); 1768 assert!(super::INDEX_HTML.contains("chainResetModalOpen")); 1769 assert!(app_js.contains("chainResetModalOpen: false")); 1770 assert!(app_js.contains("async resetLocalChain()")); 1771 assert!(app_js.contains("\"/api/settings/chain-reset\"")); 1772 assert!(app_js.contains("confirm: this.chainResetConfirm")); 1773 assert!(app_js.contains( 1774 "this.showFlash(\"Local chain deleted. Sync requested from peers.\", \"success\")" 1775 )); 1776 assert!(!app_js.contains("this.showSettingsFeedback(\"Local chain deleted.")); 1777 assert!(!app_js.contains("this.showSettingsFeedback(error.message, \"error\");\n } finally {\n this.chainResetBusy = false;")); 1778 } 1779 1780 #[tokio::test] 1781 async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() { 1782 let dir = tempfile::tempdir().unwrap(); 1783 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 1784 let wallet = Wallet::from_seed("http-chain-reset-alice"); 1785 let mut genesis = BTreeMap::new(); 1786 genesis.insert(wallet.address().to_string(), 10); 1787 let ledger = 1788 Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) 1789 .unwrap(); 1790 state.chain_store.save(&ledger.snapshot()).unwrap(); 1791 state 1792 .ui_data_store 1793 .project_snapshot(&ledger.snapshot(), true) 1794 .unwrap(); 1795 { 1796 let mut node = state.node.lock().await; 1797 *node = NodeCore::from_ledger(wallet.clone(), ledger, 1); 1798 } 1799 { 1800 let mut cache = state.ui_cache.lock().await; 1801 cache.tip_hash = Some("stale-tip".to_string()); 1802 } 1803 1804 let error = super::reset_local_chain(&state, "nope").await.unwrap_err(); 1805 assert!(error.to_string().contains("type RESET")); 1806 assert!(state.chain_store.load().unwrap().is_some()); 1807 assert!(state.node.lock().await.has_real_chain()); 1808 1809 super::reset_local_chain(&state, "RESET").await.unwrap(); 1810 1811 let node = state.node.lock().await; 1812 assert!(node.ledger().is_setup_placeholder()); 1813 assert_eq!(node.status().wallet_address, wallet.address()); 1814 drop(node); 1815 assert!(state.chain_store.load().unwrap().is_none()); 1816 assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); 1817 assert!(state.ui_cache.lock().await.tip_hash.is_none()); 1818 } 1819 1820 #[test] 1821 fn polling_refreshes_paged_datasets_without_visible_loaders() { 1822 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1823 assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); 1824 assert!( 1825 app_js.contains( 1826 "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;" 1827 ) 1828 ); 1829 assert!(app_js.contains( 1830 "async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;" 1831 )); 1832 assert!(app_js.contains("refreshPromise: null")); 1833 assert!(app_js.contains("networkHealthPromise: null")); 1834 assert!(app_js.contains("if (this.refreshPromise)")); 1835 assert!(app_js.contains("return this.refreshPromise;")); 1836 assert!(app_js.contains("options.force === true")); 1837 assert!(app_js.contains("this.setTab(this.tabFromHash());")); 1838 assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";")); 1839 assert!( 1840 app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\" && this.developmentMode();") 1841 ); 1842 assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); 1843 assert!( 1844 app_js 1845 .contains("if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");") 1846 ); 1847 assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");")); 1848 assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");")); 1849 let merge_blocks_index = app_js 1850 .find("if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true });") 1851 .expect("fresh blocks should be merged during refresh"); 1852 let paged_dataset_index = app_js 1853 .find("this.refreshPagedDataset(kind, { silent: options.silent === true })") 1854 .expect("paged datasets should refresh during refresh"); 1855 assert!(merge_blocks_index < paged_dataset_index); 1856 assert!(app_js.contains("this.refreshNetworkHealth({ silent: options.silent === true });")); 1857 assert!(app_js.contains("async refreshNetworkHealth(options = {})")); 1858 assert!(app_js.contains("if (this.networkHealthPromise) return this.networkHealthPromise;")); 1859 assert!(app_js.contains("cache: \"no-store\"")); 1860 assert!(app_js.contains("async fetchWithTimeout(path, options = {})")); 1861 assert!(app_js.contains("controller.abort()")); 1862 assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n if (!this.canUseProtectedApi()) return;")); 1863 assert!( 1864 app_js 1865 .contains("async loadNextPage(kind) {\n if (!this.canUseProtectedApi()) return;") 1866 ); 1867 assert!( 1868 app_js.contains("async loadOlderBlocks() {\n if (!this.canUseProtectedApi()) return;") 1869 ); 1870 assert!(app_js.contains("this.stopPolling();\n await this.refreshAuth();")); 1871 assert!(app_js.contains("backgroundLoading")); 1872 assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\"")); 1873 } 1874 1875 #[test] 1876 fn wallet_pending_blinded_transactions_are_labeled() { 1877 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1878 assert!(app_js.contains("Pending blind")); 1879 assert!(app_js.contains("tx.blinded")); 1880 } 1881 1882 #[test] 1883 fn mine_screen_shows_fixed_pow_reward_without_burn_slider() { 1884 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1885 assert!(app_js.contains("powMineReward()")); 1886 assert!(super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward.")); 1887 assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())")); 1888 assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\"")); 1889 assert!(super::INDEX_HTML.contains("PoB State")); 1890 assert!(super::INDEX_HTML.contains("PoW State")); 1891 assert!(super::INDEX_HTML.contains("Workers")); 1892 assert!(super::INDEX_HTML.contains("Selected Finalizer")); 1893 assert!(app_js.contains("pobStatusLabel()")); 1894 assert!(app_js.contains("powStatusShortLabel()")); 1895 assert!(app_js.contains("setPowMiningWorkers(workers)")); 1896 assert!(app_js.contains("localMiningMempoolLabel()")); 1897 assert!(app_js.contains("miningEventLog()")); 1898 assert!(app_js.contains("miningEventLimit: 1000")); 1899 assert!(app_js.contains("slice(0, this.miningEventLimit)")); 1900 assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\"")); 1901 assert!(super::INDEX_HTML.contains("miningEventLog().length === 0")); 1902 assert!(super::INDEX_HTML.contains("mining-event-empty")); 1903 assert!(app_js.contains("Resource budget:")); 1904 assert!(app_js.contains("isPowMineSuccessStatus")); 1905 assert!(app_js.contains("You mined a PoW action")); 1906 assert!(app_js.contains("Waiting for a finalizer to include it in a block.")); 1907 assert!(app_js.contains("Observed block")); 1908 assert!(app_js.contains("Finalized by")); 1909 assert!(app_js.contains("You finalized block")); 1910 assert!(app_js.contains("if (!locallyFinalized)")); 1911 assert!(app_js.contains("last?.title === title")); 1912 assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";")); 1913 assert!(app_js.contains("Automatic burn prepared at height")); 1914 assert!(app_js.contains("Eligible for the next block opportunity.")); 1915 assert!(app_js.contains("!Number.isFinite(timestampMs)")); 1916 assert!(!super::INDEX_HTML.contains("Needs burns")); 1917 } 1918 1919 #[test] 1920 fn block_detail_finalizer_opens_burn_leader_ranks_modal() { 1921 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1922 assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)")); 1923 assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\"")); 1924 assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)")); 1925 assert!(app_js.contains("selectedBurnLeaderBlock")); 1926 assert!(app_js.contains("burnLeaderRankLabel(rank)")); 1927 assert!(app_js.contains("block?.burn_leader_ranks")); 1928 assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); 1929 } 1930 1931 #[test] 1932 fn block_loading_skeleton_matches_block_card_layout() { 1933 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1934 assert!(super::INDEX_HTML.contains("block-skeleton-group")); 1935 assert!(super::INDEX_HTML.contains("block-card block-card-skeleton skeleton-card")); 1936 assert!(super::INDEX_HTML.contains("skeleton-block-height")); 1937 assert!(super::INDEX_HTML.contains("skeleton-block-meta")); 1938 assert!(super::INDEX_HTML.contains("skeleton-block-miner")); 1939 assert!(app_js.contains("const hadBlocks = this.blocks.length > 0;")); 1940 assert!(app_js.contains("const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0;")); 1941 assert!(app_js.contains("&& hadBlocks")); 1942 assert!(app_js.contains("this.$nextTick(() => this.resetBlockRailPosition());")); 1943 assert!(app_js.contains("resetBlockRailPosition()")); 1944 } 1945 1946 #[tokio::test] 1947 async fn startup_prewarm_populates_chain_view_cache_before_first_request() { 1948 let dir = tempfile::tempdir().unwrap(); 1949 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 1950 let expected_tip = { 1951 let node = state.node.lock().await; 1952 node.chain_snapshot() 1953 .blocks 1954 .last() 1955 .map(|block| block.hash.clone()) 1956 }; 1957 let sentinel = OutPoint { 1958 txid: "persisted-ui-index-sentinel".to_string(), 1959 index: 7, 1960 }; 1961 let connection = rusqlite::Connection::open(state.ui_data_store.path()).unwrap(); 1962 connection 1963 .execute( 1964 r#" 1965 INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms) 1966 VALUES (1, 1, ?1, 0) 1967 "#, 1968 rusqlite::params![expected_tip.as_ref().unwrap()], 1969 ) 1970 .unwrap(); 1971 connection 1972 .execute( 1973 "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)", 1974 rusqlite::params![expected_tip.as_ref().unwrap()], 1975 ) 1976 .unwrap(); 1977 connection 1978 .execute( 1979 r#" 1980 INSERT INTO ui_output_index (txid, output_index, address, amount) 1981 VALUES (?1, ?2, ?3, ?4) 1982 "#, 1983 rusqlite::params![&sentinel.txid, sentinel.index, "cached-address", 123_u64], 1984 ) 1985 .unwrap(); 1986 assert!(state.ui_cache.lock().await.tip_hash.is_none()); 1987 1988 super::prewarm_chain_view_cache(state.clone()) 1989 .await 1990 .unwrap(); 1991 1992 let cache = state.ui_cache.lock().await; 1993 assert_eq!(cache.tip_hash, expected_tip); 1994 assert!( 1995 cache 1996 .tip_hash 1997 .as_ref() 1998 .is_some_and(|tip_hash| cache.burn_leader_ranks_by_hash.contains_key(tip_hash)) 1999 ); 2000 assert_eq!( 2001 cache.outputs.get(&sentinel), 2002 Some(&TxOutput { 2003 address: "cached-address".to_string(), 2004 amount: 123, 2005 }) 2006 ); 2007 } 2008 2009 #[tokio::test] 2010 async fn ui_data_endpoint_catches_up_stale_projection_before_reading() { 2011 let dir = tempfile::tempdir().unwrap(); 2012 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 2013 let wallet = Wallet::from_seed("http-ui-data-catch-up-wallet"); 2014 let mut genesis = BTreeMap::new(); 2015 genesis.insert(wallet.address().to_string(), 10); 2016 let mut ledger = 2017 Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) 2018 .unwrap(); 2019 let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); 2020 ledger.submit_transaction(burn).unwrap(); 2021 let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); 2022 ledger.apply_locally_mined_block(block).unwrap(); 2023 let expected_tip = ledger.status().tip_hash; 2024 { 2025 let mut node = state.node.lock().await; 2026 *node = NodeCore::from_ledger(wallet.clone(), ledger, 0); 2027 } 2028 2029 assert!(state.chain_store.load().unwrap().is_none()); 2030 assert!(!state.ui_data_store.is_projected_to(&expected_tip).unwrap()); 2031 2032 let Json(page) = 2033 super::api_wallet_utxos(State(state.clone()), Query(super::PageQuery::default())).await; 2034 2035 assert!( 2036 page.items 2037 .iter() 2038 .any(|utxo| utxo.address == wallet.address()) 2039 ); 2040 let persisted_tip = state 2041 .chain_store 2042 .load() 2043 .unwrap() 2044 .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone())); 2045 assert_eq!(persisted_tip.as_deref(), Some(expected_tip.as_str())); 2046 assert!(state.ui_data_store.is_projected_to(&expected_tip).unwrap()); 2047 } 2048 2049 async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { 2050 config_store::save(&config_path, &config).unwrap(); 2051 let wallet_path = config_path.with_file_name("wallet.json"); 2052 let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); 2053 let ledger = Ledger::new(BTreeMap::new(), 1); 2054 let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); 2055 let peers = Arc::new(Mutex::new(PeerBook::default())); 2056 let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); 2057 let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) 2058 .expect("test chain store should open"); 2059 let ui_data_store = SqliteUiDataStore::open(config_path.with_file_name("ui_data.sqlite3")) 2060 .expect("test UI data store should open"); 2061 HttpState { 2062 node, 2063 peers, 2064 gossip, 2065 ui_config: Arc::new(Mutex::new( 2066 config_store::load_or_create(&config_path).unwrap(), 2067 )), 2068 config_path, 2069 chain_store, 2070 ui_data_store, 2071 wallet_path, 2072 stratum: StratumStatus { 2073 enabled: false, 2074 listen_addr: None, 2075 }, 2076 auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), 2077 auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), 2078 ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), 2079 ui_data_refresh: Arc::new(Mutex::new(())), 2080 } 2081 } 2082 2083 fn auth_test_app(state: HttpState) -> Router { 2084 Router::new() 2085 .route("/api/auth/status", get(api_auth_status)) 2086 .route("/api/auth/setup", post(api_auth_setup_form)) 2087 .route("/api/auth/login", post(api_auth_login_form)) 2088 .route( 2089 "/api/auth/change-password", 2090 post(api_auth_change_password_form), 2091 ) 2092 .route("/favicon.ico", get(super::favicon)) 2093 .route("/api/protected", get(protected_auth_test_endpoint)) 2094 .layer(middleware::from_fn_with_state( 2095 state.clone(), 2096 require_auth_middleware, 2097 )) 2098 .with_state(state) 2099 } 2100 2101 async fn protected_auth_test_endpoint() -> &'static str { 2102 "protected" 2103 } 2104 2105 struct TestHttpResponse { 2106 status: StatusCode, 2107 headers: HeaderMap, 2108 body: String, 2109 } 2110 2111 async fn http_request( 2112 app: Router, 2113 method: Method, 2114 path: &str, 2115 cookie: Option<&str>, 2116 body: &str, 2117 ) -> TestHttpResponse { 2118 let mut builder = Request::builder() 2119 .method(method.clone()) 2120 .uri(path) 2121 .header(header::ACCEPT, "application/json") 2122 .header(header::HOST, "127.0.0.1:18661") 2123 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); 2124 if matches!(method, Method::POST | Method::DELETE) { 2125 builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661"); 2126 } 2127 if let Some(cookie) = cookie { 2128 builder = builder.header(header::COOKIE, cookie); 2129 } 2130 let response = app 2131 .oneshot(builder.body(Body::from(body.to_string())).unwrap()) 2132 .await 2133 .unwrap(); 2134 let status = response.status(); 2135 let headers = response.headers().clone(); 2136 let body = String::from_utf8( 2137 to_bytes(response.into_body(), usize::MAX) 2138 .await 2139 .unwrap() 2140 .to_vec(), 2141 ) 2142 .unwrap(); 2143 TestHttpResponse { 2144 status, 2145 headers, 2146 body, 2147 } 2148 } 2149 2150 fn set_cookie_pair(headers: &HeaderMap) -> String { 2151 let header = headers 2152 .get(header::SET_COOKIE) 2153 .and_then(|value| value.to_str().ok()) 2154 .expect("response should include Set-Cookie header"); 2155 header.split(';').next().unwrap().to_string() 2156 } 2157 2158 #[tokio::test] 2159 async fn burn_settings_config_persistence_updates_config_file() { 2160 let dir = tempfile::tempdir().unwrap(); 2161 let config_path = dir.path().join("config.json"); 2162 let ui_config = Arc::new(Mutex::new(UiConfig { 2163 setup_complete: true, 2164 ..UiConfig::default() 2165 })); 2166 let initial_config = ui_config.lock().await.clone(); 2167 config_store::save(&config_path, &initial_config).expect("initial config should save"); 2168 2169 persist_burn_settings_config( 2170 &ui_config, 2171 &config_path, 2172 true, 2173 50 * MICRO_IUNA, 2174 3 * MICRO_IUNA, 2175 ) 2176 .await 2177 .unwrap(); 2178 let config = config_store::load_or_create(&config_path).unwrap(); 2179 2180 assert!(config.mining_enabled); 2181 assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); 2182 assert_eq!(config.burn_fee, 3 * MICRO_IUNA); 2183 } 2184 2185 #[tokio::test] 2186 async fn enabled_burn_settings_reject_zero_amount() { 2187 let dir = tempfile::tempdir().unwrap(); 2188 let state = auth_test_state( 2189 dir.path().join("config.json"), 2190 UiConfig { 2191 setup_complete: true, 2192 ..UiConfig::default() 2193 }, 2194 ) 2195 .await; 2196 2197 let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA) 2198 .await 2199 .unwrap_err(); 2200 2201 assert!(format!("{error:#}").contains("greater than zero")); 2202 } 2203 2204 #[tokio::test] 2205 async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { 2206 let dir = tempfile::tempdir().unwrap(); 2207 let config_path = dir.path().join("config.json"); 2208 let state = auth_test_state( 2209 config_path.clone(), 2210 UiConfig { 2211 setup_complete: true, 2212 ..UiConfig::default() 2213 }, 2214 ) 2215 .await; 2216 let wallet = Wallet::from_seed("metrics-setting-wallet"); 2217 let mut genesis = BTreeMap::new(); 2218 genesis.insert(wallet.address().to_string(), 10); 2219 let ledger = Ledger::new_with_genesis_burns( 2220 genesis, 2221 vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], 2222 1, 2223 ) 2224 .unwrap(); 2225 *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); 2226 2227 super::set_keep_track_of_metrics(&state, true) 2228 .await 2229 .unwrap(); 2230 let config = config_store::load_or_create(&config_path).unwrap(); 2231 assert!(config.keep_track_of_metrics); 2232 assert!(!state.ui_data_store.load_metrics().unwrap().is_empty()); 2233 2234 super::set_keep_track_of_metrics(&state, false) 2235 .await 2236 .unwrap(); 2237 let config = config_store::load_or_create(&config_path).unwrap(); 2238 assert!(!config.keep_track_of_metrics); 2239 assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); 2240 } 2241 2242 #[tokio::test] 2243 async fn pow_mining_config_persistence_updates_config_file() { 2244 let dir = tempfile::tempdir().unwrap(); 2245 let config_path = dir.path().join("config.json"); 2246 let ui_config = Arc::new(Mutex::new(UiConfig { 2247 setup_complete: true, 2248 ..UiConfig::default() 2249 })); 2250 let initial_config = ui_config.lock().await.clone(); 2251 config_store::save(&config_path, &initial_config).expect("initial config should save"); 2252 2253 persist_pow_mining_config(&ui_config, &config_path, true, 4) 2254 .await 2255 .unwrap(); 2256 let config = config_store::load_or_create(&config_path).unwrap(); 2257 2258 assert!(config.pow_mining_enabled); 2259 assert_eq!(config.pow_mining_workers, 4); 2260 } 2261 2262 #[test] 2263 fn transfer_form_requires_recipient_amount_and_fee() { 2264 let error = validate_transfer_form(TransferForm { 2265 to: " ".to_string(), 2266 amount: 1, 2267 fee_per_byte: Some(1), 2268 utxos: String::new(), 2269 }) 2270 .unwrap_err(); 2271 assert!(error.to_string().contains("recipient is required")); 2272 2273 let error = validate_transfer_form(TransferForm { 2274 to: "abc".to_string(), 2275 amount: 0, 2276 fee_per_byte: Some(1), 2277 utxos: String::new(), 2278 }) 2279 .unwrap_err(); 2280 assert!( 2281 error 2282 .to_string() 2283 .contains("amount must be greater than zero") 2284 ); 2285 2286 let error = validate_transfer_form(TransferForm { 2287 to: "abc".to_string(), 2288 amount: 1, 2289 fee_per_byte: None, 2290 utxos: String::new(), 2291 }) 2292 .unwrap_err(); 2293 assert!(error.to_string().contains("fee per byte is required")); 2294 } 2295 2296 #[test] 2297 fn burn_and_mine_forms_require_fee_per_byte() { 2298 let burn = required_fee_per_byte_burn(&super::BurnSettingsForm { 2299 enabled: Some(true), 2300 amount: 1, 2301 fee_per_byte: None, 2302 }) 2303 .unwrap_err(); 2304 assert!(burn.to_string().contains("fee per byte is required")); 2305 } 2306 2307 #[test] 2308 fn transfer_form_trims_recipient() { 2309 let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { 2310 to: " abc ".to_string(), 2311 amount: 2, 2312 fee_per_byte: Some(3), 2313 utxos: String::new(), 2314 }) 2315 .unwrap(); 2316 2317 assert_eq!(to, "abc"); 2318 assert_eq!(amount, 2); 2319 assert_eq!(fee, 3); 2320 assert!(utxos.is_empty()); 2321 } 2322 2323 #[test] 2324 fn transfer_form_parses_selected_utxos() { 2325 let (_, _, _, utxos) = validate_transfer_form(TransferForm { 2326 to: "abc".to_string(), 2327 amount: 2, 2328 fee_per_byte: Some(3), 2329 utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(), 2330 }) 2331 .unwrap(); 2332 2333 assert_eq!( 2334 utxos, 2335 vec![ 2336 OutPoint { 2337 txid: "tx-one".to_string(), 2338 index: 0 2339 }, 2340 OutPoint { 2341 txid: "tx:with:colons".to_string(), 2342 index: 7 2343 } 2344 ] 2345 ); 2346 }