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