tests.rs (82547B)
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 blocks_endpoint_uses_cached_block_projection_without_cloning_full_ui_index() { 1499 let api_rs = include_str!("api.rs"); 1500 assert!(api_rs.contains("cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks)")); 1501 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,")); 1502 } 1503 1504 #[test] 1505 fn mempool_and_wallet_transaction_endpoints_use_narrow_output_lookups() { 1506 let api_rs = include_str!("api.rs"); 1507 assert!(api_rs.contains("load_outputs_for_outpoints(&state, required_outputs)")); 1508 assert!(api_rs.contains("load_outputs_for_outpoints(&state, pending_required_outputs)")); 1509 assert!(api_rs.contains("load_outputs_for_outpoints(&state, confirmed_required_outputs)")); 1510 assert!(!api_rs.contains("let mut outputs = view.outputs;")); 1511 } 1512 1513 #[test] 1514 fn http_server_logs_slow_api_requests() { 1515 let http_rs = include_str!("../http.rs"); 1516 assert!(http_rs.contains("const SLOW_UI_REQUEST_LOG_MS: u128 = 250;")); 1517 assert!(http_rs.contains("async fn log_slow_api_request")); 1518 assert!(http_rs.contains("slow UI API request: {method} {path}")); 1519 } 1520 1521 #[test] 1522 fn fee_estimate_polling_is_tab_scoped() { 1523 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1524 let refresh = app_js 1525 .split("async refreshFeeEstimates()") 1526 .nth(1) 1527 .expect("refreshFeeEstimates should exist") 1528 .split("async refreshBurnFeeEstimate()") 1529 .next() 1530 .expect("refreshFeeEstimates body should precede burn fee estimate"); 1531 1532 assert!(refresh.contains("if (this.tab === \"wallet\")")); 1533 assert!(refresh.contains("await this.refreshTransferFeeEstimate();")); 1534 assert!(refresh.contains("if (this.tab === \"mining\")")); 1535 assert!(refresh.contains("this.refreshBurnFeeEstimate()")); 1536 assert!(refresh.contains("this.refreshMineFeeEstimate()")); 1537 } 1538 1539 #[test] 1540 fn transfer_utxo_selection_supports_shift_click_ranges() { 1541 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1542 assert!( 1543 super::INDEX_HTML 1544 .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\"") 1545 ); 1546 assert!(super::INDEX_HTML.contains(".send-utxo-option {")); 1547 assert!(super::INDEX_HTML.contains("user-select: none;")); 1548 assert!(super::INDEX_HTML.contains(".send-utxo-option input {")); 1549 assert!(super::INDEX_HTML.contains("pointer-events: none;")); 1550 assert!( 1551 !super::INDEX_HTML 1552 .contains("x-model=\"selectedTransferUtxos\" @click=\"toggleTransferUtxoSelection") 1553 ); 1554 assert!(!super::INDEX_HTML.contains("@change=\"toggleTransferUtxoSelection($event, utxo)\"")); 1555 assert!( 1556 super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"") 1557 ); 1558 assert!( 1559 !super::INDEX_HTML 1560 .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=") 1561 ); 1562 assert!(app_js.contains("lastSelectedTransferUtxo: null")); 1563 assert!(app_js.contains("toggleTransferUtxoSelection(event, utxo)")); 1564 assert!(app_js.contains("const checked = !selected.has(outpoint);")); 1565 assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0")); 1566 assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort")); 1567 } 1568 1569 #[test] 1570 fn reveal_mempool_items_show_unknown_fee_label() { 1571 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1572 assert!(app_js.contains("txFeeLabel(tx)")); 1573 assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\"")); 1574 assert!(app_js.contains("unknown until reveal")); 1575 assert!( 1576 app_js.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")") 1577 ); 1578 assert!(app_js.contains("mempoolFirstSeenHeights")); 1579 assert!(app_js.contains("mempoolFirstSeenAt")); 1580 assert!( 1581 app_js.contains("this.trackMempoolFirstSeenHeights({ append: options.replace !== true })") 1582 ); 1583 assert!(app_js.contains("return rightSeenAt - leftSeenAt")); 1584 assert!(app_js.contains("syncMempoolBlockMarker")); 1585 assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight")); 1586 assert!(app_js.contains("mempoolItemClass")); 1587 assert!(app_js.contains("walletTxTimeLabel(tx)")); 1588 assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms")); 1589 assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\"")); 1590 assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\"")); 1591 assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>")); 1592 assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\"")); 1593 } 1594 1595 #[test] 1596 fn wallet_screen_includes_address_book_alias_controls() { 1597 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1598 assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); 1599 assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); 1600 assert!(super::INDEX_HTML.contains("addressBookEntries()")); 1601 assert!(super::INDEX_HTML.contains("openAddressBookModal()")); 1602 assert!(super::INDEX_HTML.contains("addressBookModalOpen")); 1603 assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); 1604 assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); 1605 assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); 1606 assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); 1607 assert!( 1608 super::INDEX_HTML.contains("removeAddressBookEntry({ address: addressBookEditingAddress") 1609 ); 1610 assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); 1611 assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); 1612 assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); 1613 assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); 1614 assert!(app_js.contains("addressBook: {}")); 1615 assert!(app_js.contains("addressBookVersion: 0")); 1616 assert!(app_js.contains("addressBookPickerOpen: false")); 1617 assert!(app_js.contains("this.config.address_book || this.config.addressBook")); 1618 assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); 1619 assert!(app_js.contains("async saveAddressBookEntry()")); 1620 assert!(app_js.contains("Address is already saved")); 1621 assert!(app_js.contains("validAddressBookAddress(address)")); 1622 assert!(app_js.contains("openAddressBookPicker()")); 1623 assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); 1624 assert!(app_js.contains("\"/api/address-book\"")); 1625 assert!(app_js.contains("addressLabel(address)")); 1626 assert!(app_js.contains("shortAddressLabel(address)")); 1627 } 1628 1629 #[test] 1630 fn initial_setup_includes_node_mode_choices() { 1631 assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); 1632 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); 1633 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')")); 1634 assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')")); 1635 assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'")); 1636 assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\"")); 1637 assert!(super::INDEX_HTML.contains("Change later in Settings")); 1638 } 1639 1640 #[test] 1641 fn setup_completion_refreshes_chain_data() { 1642 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1643 assert!( 1644 app_js.contains("await this.refresh({ force: true });\n this.setupFeedback = null;") 1645 ); 1646 assert!( 1647 !app_js.contains( 1648 "await this.refreshConfig();\n await this.resetPagedDataset(\"peer\");" 1649 ) 1650 ); 1651 } 1652 1653 #[test] 1654 fn setup_completion_applies_selected_node_mode() { 1655 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1656 assert!(app_js.contains("setupNodeMode: \"wallet\"")); 1657 assert!(app_js.contains("async applySetupNodeMode()")); 1658 assert!(app_js.contains("await this.applySetupNodeMode();")); 1659 assert!(app_js.contains("\"/api/settings/p2p-inbound\"")); 1660 assert!(app_js.contains("bind_port: this.p2pBindPortValue()")); 1661 assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")")); 1662 } 1663 1664 #[test] 1665 fn p2p_bind_port_changes_show_global_restart_notice() { 1666 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1667 assert!(super::INDEX_HTML.contains("Bind port")); 1668 assert!(super::INDEX_HTML.contains("persistent-banner")); 1669 assert!(super::INDEX_HTML.contains("p2pRestartRequired()")); 1670 assert!(app_js.contains("p2pBindPort: 9444")); 1671 assert!(app_js.contains("p2pConfiguredBindAddr()")); 1672 assert!(app_js.contains("p2pRestartMessage()")); 1673 assert!(app_js.contains("Restart iuna to close the public P2P listener.")); 1674 assert!(app_js.contains("0.0.0.0:${port}")); 1675 } 1676 1677 #[test] 1678 fn settings_includes_dangerous_chain_reset_flow() { 1679 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1680 assert!(super::INDEX_HTML.contains("Danger Zone")); 1681 assert!(super::INDEX_HTML.contains("Delete local chain")); 1682 assert!(super::INDEX_HTML.contains("Type <strong>RESET</strong> to confirm.")); 1683 assert!(super::INDEX_HTML.contains("class=\"danger\"")); 1684 assert!(super::INDEX_HTML.contains("chainResetModalOpen")); 1685 assert!(app_js.contains("chainResetModalOpen: false")); 1686 assert!(app_js.contains("async resetLocalChain()")); 1687 assert!(app_js.contains("\"/api/settings/chain-reset\"")); 1688 assert!(app_js.contains("confirm: this.chainResetConfirm")); 1689 assert!(app_js.contains( 1690 "this.showFlash(\"Local chain deleted. Sync requested from peers.\", \"success\")" 1691 )); 1692 assert!(!app_js.contains("this.showSettingsFeedback(\"Local chain deleted.")); 1693 assert!(!app_js.contains("this.showSettingsFeedback(error.message, \"error\");\n } finally {\n this.chainResetBusy = false;")); 1694 } 1695 1696 #[tokio::test] 1697 async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() { 1698 let dir = tempfile::tempdir().unwrap(); 1699 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 1700 let wallet = Wallet::from_seed("http-chain-reset-alice"); 1701 let mut genesis = BTreeMap::new(); 1702 genesis.insert(wallet.address().to_string(), 10); 1703 let ledger = 1704 Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) 1705 .unwrap(); 1706 state.chain_store.save(&ledger.snapshot()).unwrap(); 1707 state 1708 .ui_data_store 1709 .project_snapshot(&ledger.snapshot(), true) 1710 .unwrap(); 1711 { 1712 let mut node = state.node.lock().await; 1713 *node = NodeCore::from_ledger(wallet.clone(), ledger, 1); 1714 } 1715 { 1716 let mut cache = state.ui_cache.lock().await; 1717 cache.tip_hash = Some("stale-tip".to_string()); 1718 } 1719 1720 let error = super::reset_local_chain(&state, "nope").await.unwrap_err(); 1721 assert!(error.to_string().contains("type RESET")); 1722 assert!(state.chain_store.load().unwrap().is_some()); 1723 assert!(state.node.lock().await.has_real_chain()); 1724 1725 super::reset_local_chain(&state, "RESET").await.unwrap(); 1726 1727 let node = state.node.lock().await; 1728 assert!(node.ledger().is_setup_placeholder()); 1729 assert_eq!(node.status().wallet_address, wallet.address()); 1730 drop(node); 1731 assert!(state.chain_store.load().unwrap().is_none()); 1732 assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); 1733 assert!(state.ui_cache.lock().await.tip_hash.is_none()); 1734 } 1735 1736 #[test] 1737 fn polling_refreshes_paged_datasets_without_visible_loaders() { 1738 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1739 assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); 1740 assert!( 1741 app_js.contains( 1742 "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;" 1743 ) 1744 ); 1745 assert!(app_js.contains( 1746 "async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;" 1747 )); 1748 assert!(app_js.contains("refreshPromise: null")); 1749 assert!(app_js.contains("networkHealthPromise: null")); 1750 assert!(app_js.contains("if (this.refreshPromise)")); 1751 assert!(app_js.contains("return this.refreshPromise;")); 1752 assert!(app_js.contains("options.force === true")); 1753 assert!(app_js.contains("this.setTab(this.tabFromHash());")); 1754 assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";")); 1755 assert!(app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\";")); 1756 assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); 1757 assert!( 1758 app_js 1759 .contains("if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");") 1760 ); 1761 assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");")); 1762 assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");")); 1763 let merge_blocks_index = app_js 1764 .find("if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true });") 1765 .expect("fresh blocks should be merged during refresh"); 1766 let paged_dataset_index = app_js 1767 .find("this.refreshPagedDataset(kind, { silent: options.silent === true })") 1768 .expect("paged datasets should refresh during refresh"); 1769 assert!(merge_blocks_index < paged_dataset_index); 1770 assert!(app_js.contains("this.refreshNetworkHealth({ silent: options.silent === true });")); 1771 assert!(app_js.contains("async refreshNetworkHealth(options = {})")); 1772 assert!(app_js.contains("if (this.networkHealthPromise) return this.networkHealthPromise;")); 1773 assert!(app_js.contains("cache: \"no-store\"")); 1774 assert!(app_js.contains("async fetchWithTimeout(path, options = {})")); 1775 assert!(app_js.contains("controller.abort()")); 1776 assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n if (!this.canUseProtectedApi()) return;")); 1777 assert!( 1778 app_js 1779 .contains("async loadNextPage(kind) {\n if (!this.canUseProtectedApi()) return;") 1780 ); 1781 assert!( 1782 app_js.contains("async loadOlderBlocks() {\n if (!this.canUseProtectedApi()) return;") 1783 ); 1784 assert!(app_js.contains("this.stopPolling();\n await this.refreshAuth();")); 1785 assert!(app_js.contains("backgroundLoading")); 1786 assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\"")); 1787 } 1788 1789 #[test] 1790 fn wallet_pending_blinded_transactions_are_labeled() { 1791 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1792 assert!(app_js.contains("Pending blind")); 1793 assert!(app_js.contains("tx.blinded")); 1794 } 1795 1796 #[test] 1797 fn mine_screen_shows_fixed_pow_reward_without_burn_slider() { 1798 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1799 assert!(app_js.contains("powMineReward()")); 1800 assert!(super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward.")); 1801 assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())")); 1802 assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\"")); 1803 assert!(super::INDEX_HTML.contains("PoB State")); 1804 assert!(super::INDEX_HTML.contains("PoW State")); 1805 assert!(super::INDEX_HTML.contains("Workers")); 1806 assert!(super::INDEX_HTML.contains("Selected Finalizer")); 1807 assert!(app_js.contains("pobStatusLabel()")); 1808 assert!(app_js.contains("powStatusShortLabel()")); 1809 assert!(app_js.contains("setPowMiningWorkers(workers)")); 1810 assert!(app_js.contains("localMiningMempoolLabel()")); 1811 assert!(app_js.contains("miningEventLog()")); 1812 assert!(app_js.contains("miningEventLimit: 1000")); 1813 assert!(app_js.contains("slice(0, this.miningEventLimit)")); 1814 assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\"")); 1815 assert!(super::INDEX_HTML.contains("miningEventLog().length === 0")); 1816 assert!(super::INDEX_HTML.contains("mining-event-empty")); 1817 assert!(app_js.contains("Resource budget:")); 1818 assert!(app_js.contains("isPowMineSuccessStatus")); 1819 assert!(app_js.contains("You mined a PoW action")); 1820 assert!(app_js.contains("Waiting for a finalizer to include it in a block.")); 1821 assert!(app_js.contains("Observed block")); 1822 assert!(app_js.contains("Finalized by")); 1823 assert!(app_js.contains("You finalized block")); 1824 assert!(app_js.contains("if (!locallyFinalized)")); 1825 assert!(app_js.contains("last?.title === title")); 1826 assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";")); 1827 assert!(app_js.contains("Automatic burn prepared at height")); 1828 assert!(app_js.contains("Eligible for the next block opportunity.")); 1829 assert!(app_js.contains("!Number.isFinite(timestampMs)")); 1830 assert!(!super::INDEX_HTML.contains("Needs burns")); 1831 } 1832 1833 #[test] 1834 fn block_detail_finalizer_opens_burn_leader_ranks_modal() { 1835 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1836 assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)")); 1837 assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\"")); 1838 assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)")); 1839 assert!(app_js.contains("selectedBurnLeaderBlock")); 1840 assert!(app_js.contains("burnLeaderRankLabel(rank)")); 1841 assert!(app_js.contains("block?.burn_leader_ranks")); 1842 assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); 1843 } 1844 1845 #[test] 1846 fn block_loading_skeleton_matches_block_card_layout() { 1847 let app_js = include_str!("../../../www/assets/iuna-ui.js"); 1848 assert!(super::INDEX_HTML.contains("block-skeleton-group")); 1849 assert!(super::INDEX_HTML.contains("block-card block-card-skeleton skeleton-card")); 1850 assert!(super::INDEX_HTML.contains("skeleton-block-height")); 1851 assert!(super::INDEX_HTML.contains("skeleton-block-meta")); 1852 assert!(super::INDEX_HTML.contains("skeleton-block-miner")); 1853 assert!(app_js.contains("const hadBlocks = this.blocks.length > 0;")); 1854 assert!(app_js.contains("const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0;")); 1855 assert!(app_js.contains("&& hadBlocks")); 1856 assert!(app_js.contains("this.$nextTick(() => this.resetBlockRailPosition());")); 1857 assert!(app_js.contains("resetBlockRailPosition()")); 1858 } 1859 1860 #[tokio::test] 1861 async fn startup_prewarm_populates_chain_view_cache_before_first_request() { 1862 let dir = tempfile::tempdir().unwrap(); 1863 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 1864 let expected_tip = { 1865 let node = state.node.lock().await; 1866 node.chain_snapshot() 1867 .blocks 1868 .last() 1869 .map(|block| block.hash.clone()) 1870 }; 1871 let sentinel = OutPoint { 1872 txid: "persisted-ui-index-sentinel".to_string(), 1873 index: 7, 1874 }; 1875 let connection = rusqlite::Connection::open(state.ui_data_store.path()).unwrap(); 1876 connection 1877 .execute( 1878 r#" 1879 INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms) 1880 VALUES (1, 1, ?1, 0) 1881 "#, 1882 rusqlite::params![expected_tip.as_ref().unwrap()], 1883 ) 1884 .unwrap(); 1885 connection 1886 .execute( 1887 "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)", 1888 rusqlite::params![expected_tip.as_ref().unwrap()], 1889 ) 1890 .unwrap(); 1891 connection 1892 .execute( 1893 r#" 1894 INSERT INTO ui_output_index (txid, output_index, address, amount) 1895 VALUES (?1, ?2, ?3, ?4) 1896 "#, 1897 rusqlite::params![&sentinel.txid, sentinel.index, "cached-address", 123_u64], 1898 ) 1899 .unwrap(); 1900 assert!(state.ui_cache.lock().await.tip_hash.is_none()); 1901 1902 super::prewarm_chain_view_cache(state.clone()) 1903 .await 1904 .unwrap(); 1905 1906 let cache = state.ui_cache.lock().await; 1907 assert_eq!(cache.tip_hash, expected_tip); 1908 assert!( 1909 cache 1910 .tip_hash 1911 .as_ref() 1912 .is_some_and(|tip_hash| cache.burn_leader_ranks_by_hash.contains_key(tip_hash)) 1913 ); 1914 assert_eq!( 1915 cache.outputs.get(&sentinel), 1916 Some(&TxOutput { 1917 address: "cached-address".to_string(), 1918 amount: 123, 1919 }) 1920 ); 1921 } 1922 1923 #[tokio::test] 1924 async fn ui_data_endpoint_catches_up_stale_projection_before_reading() { 1925 let dir = tempfile::tempdir().unwrap(); 1926 let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; 1927 let wallet = Wallet::from_seed("http-ui-data-catch-up-wallet"); 1928 let mut genesis = BTreeMap::new(); 1929 genesis.insert(wallet.address().to_string(), 10); 1930 let mut ledger = 1931 Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) 1932 .unwrap(); 1933 let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); 1934 ledger.submit_transaction(burn).unwrap(); 1935 let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); 1936 ledger.apply_locally_mined_block(block).unwrap(); 1937 let expected_tip = ledger.status().tip_hash; 1938 { 1939 let mut node = state.node.lock().await; 1940 *node = NodeCore::from_ledger(wallet.clone(), ledger, 0); 1941 } 1942 1943 assert!(state.chain_store.load().unwrap().is_none()); 1944 assert!(!state.ui_data_store.is_projected_to(&expected_tip).unwrap()); 1945 1946 let Json(page) = 1947 super::api_wallet_utxos(State(state.clone()), Query(super::PageQuery::default())).await; 1948 1949 assert!( 1950 page.items 1951 .iter() 1952 .any(|utxo| utxo.address == wallet.address()) 1953 ); 1954 let persisted_tip = state 1955 .chain_store 1956 .load() 1957 .unwrap() 1958 .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone())); 1959 assert_eq!(persisted_tip.as_deref(), Some(expected_tip.as_str())); 1960 assert!(state.ui_data_store.is_projected_to(&expected_tip).unwrap()); 1961 } 1962 1963 async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { 1964 config_store::save(&config_path, &config).unwrap(); 1965 let wallet_path = config_path.with_file_name("wallet.json"); 1966 let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); 1967 let ledger = Ledger::new(BTreeMap::new(), 1); 1968 let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); 1969 let peers = Arc::new(Mutex::new(PeerBook::default())); 1970 let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); 1971 let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) 1972 .expect("test chain store should open"); 1973 let ui_data_store = SqliteUiDataStore::open(config_path.with_file_name("ui_data.sqlite3")) 1974 .expect("test UI data store should open"); 1975 HttpState { 1976 node, 1977 peers, 1978 gossip, 1979 ui_config: Arc::new(Mutex::new( 1980 config_store::load_or_create(&config_path).unwrap(), 1981 )), 1982 config_path, 1983 chain_store, 1984 ui_data_store, 1985 wallet_path, 1986 stratum: StratumStatus { 1987 enabled: false, 1988 listen_addr: None, 1989 }, 1990 auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), 1991 auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), 1992 ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), 1993 ui_data_refresh: Arc::new(Mutex::new(())), 1994 } 1995 } 1996 1997 fn auth_test_app(state: HttpState) -> Router { 1998 Router::new() 1999 .route("/api/auth/status", get(api_auth_status)) 2000 .route("/api/auth/setup", post(api_auth_setup_form)) 2001 .route("/api/auth/login", post(api_auth_login_form)) 2002 .route( 2003 "/api/auth/change-password", 2004 post(api_auth_change_password_form), 2005 ) 2006 .route("/favicon.ico", get(super::favicon)) 2007 .route("/api/protected", get(protected_auth_test_endpoint)) 2008 .layer(middleware::from_fn_with_state( 2009 state.clone(), 2010 require_auth_middleware, 2011 )) 2012 .with_state(state) 2013 } 2014 2015 async fn protected_auth_test_endpoint() -> &'static str { 2016 "protected" 2017 } 2018 2019 struct TestHttpResponse { 2020 status: StatusCode, 2021 headers: HeaderMap, 2022 body: String, 2023 } 2024 2025 async fn http_request( 2026 app: Router, 2027 method: Method, 2028 path: &str, 2029 cookie: Option<&str>, 2030 body: &str, 2031 ) -> TestHttpResponse { 2032 let mut builder = Request::builder() 2033 .method(method.clone()) 2034 .uri(path) 2035 .header(header::ACCEPT, "application/json") 2036 .header(header::HOST, "127.0.0.1:18661") 2037 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); 2038 if matches!(method, Method::POST | Method::DELETE) { 2039 builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661"); 2040 } 2041 if let Some(cookie) = cookie { 2042 builder = builder.header(header::COOKIE, cookie); 2043 } 2044 let response = app 2045 .oneshot(builder.body(Body::from(body.to_string())).unwrap()) 2046 .await 2047 .unwrap(); 2048 let status = response.status(); 2049 let headers = response.headers().clone(); 2050 let body = String::from_utf8( 2051 to_bytes(response.into_body(), usize::MAX) 2052 .await 2053 .unwrap() 2054 .to_vec(), 2055 ) 2056 .unwrap(); 2057 TestHttpResponse { 2058 status, 2059 headers, 2060 body, 2061 } 2062 } 2063 2064 fn set_cookie_pair(headers: &HeaderMap) -> String { 2065 let header = headers 2066 .get(header::SET_COOKIE) 2067 .and_then(|value| value.to_str().ok()) 2068 .expect("response should include Set-Cookie header"); 2069 header.split(';').next().unwrap().to_string() 2070 } 2071 2072 #[tokio::test] 2073 async fn burn_settings_config_persistence_updates_config_file() { 2074 let dir = tempfile::tempdir().unwrap(); 2075 let config_path = dir.path().join("config.json"); 2076 let ui_config = Arc::new(Mutex::new(UiConfig { 2077 setup_complete: true, 2078 ..UiConfig::default() 2079 })); 2080 let initial_config = ui_config.lock().await.clone(); 2081 config_store::save(&config_path, &initial_config).expect("initial config should save"); 2082 2083 persist_burn_settings_config( 2084 &ui_config, 2085 &config_path, 2086 true, 2087 50 * MICRO_IUNA, 2088 3 * MICRO_IUNA, 2089 ) 2090 .await 2091 .unwrap(); 2092 let config = config_store::load_or_create(&config_path).unwrap(); 2093 2094 assert!(config.mining_enabled); 2095 assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); 2096 assert_eq!(config.burn_fee, 3 * MICRO_IUNA); 2097 } 2098 2099 #[tokio::test] 2100 async fn enabled_burn_settings_reject_zero_amount() { 2101 let dir = tempfile::tempdir().unwrap(); 2102 let state = auth_test_state( 2103 dir.path().join("config.json"), 2104 UiConfig { 2105 setup_complete: true, 2106 ..UiConfig::default() 2107 }, 2108 ) 2109 .await; 2110 2111 let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA) 2112 .await 2113 .unwrap_err(); 2114 2115 assert!(format!("{error:#}").contains("greater than zero")); 2116 } 2117 2118 #[tokio::test] 2119 async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { 2120 let dir = tempfile::tempdir().unwrap(); 2121 let config_path = dir.path().join("config.json"); 2122 let state = auth_test_state( 2123 config_path.clone(), 2124 UiConfig { 2125 setup_complete: true, 2126 ..UiConfig::default() 2127 }, 2128 ) 2129 .await; 2130 let wallet = Wallet::from_seed("metrics-setting-wallet"); 2131 let mut genesis = BTreeMap::new(); 2132 genesis.insert(wallet.address().to_string(), 10); 2133 let ledger = Ledger::new_with_genesis_burns( 2134 genesis, 2135 vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], 2136 1, 2137 ) 2138 .unwrap(); 2139 *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); 2140 2141 super::set_keep_track_of_metrics(&state, true) 2142 .await 2143 .unwrap(); 2144 let config = config_store::load_or_create(&config_path).unwrap(); 2145 assert!(config.keep_track_of_metrics); 2146 assert!(!state.ui_data_store.load_metrics().unwrap().is_empty()); 2147 2148 super::set_keep_track_of_metrics(&state, false) 2149 .await 2150 .unwrap(); 2151 let config = config_store::load_or_create(&config_path).unwrap(); 2152 assert!(!config.keep_track_of_metrics); 2153 assert!(state.ui_data_store.load_metrics().unwrap().is_empty()); 2154 } 2155 2156 #[tokio::test] 2157 async fn pow_mining_config_persistence_updates_config_file() { 2158 let dir = tempfile::tempdir().unwrap(); 2159 let config_path = dir.path().join("config.json"); 2160 let ui_config = Arc::new(Mutex::new(UiConfig { 2161 setup_complete: true, 2162 ..UiConfig::default() 2163 })); 2164 let initial_config = ui_config.lock().await.clone(); 2165 config_store::save(&config_path, &initial_config).expect("initial config should save"); 2166 2167 persist_pow_mining_config(&ui_config, &config_path, true, 4) 2168 .await 2169 .unwrap(); 2170 let config = config_store::load_or_create(&config_path).unwrap(); 2171 2172 assert!(config.pow_mining_enabled); 2173 assert_eq!(config.pow_mining_workers, 4); 2174 } 2175 2176 #[test] 2177 fn transfer_form_requires_recipient_amount_and_fee() { 2178 let error = validate_transfer_form(TransferForm { 2179 to: " ".to_string(), 2180 amount: 1, 2181 fee_per_byte: Some(1), 2182 utxos: String::new(), 2183 }) 2184 .unwrap_err(); 2185 assert!(error.to_string().contains("recipient is required")); 2186 2187 let error = validate_transfer_form(TransferForm { 2188 to: "abc".to_string(), 2189 amount: 0, 2190 fee_per_byte: Some(1), 2191 utxos: String::new(), 2192 }) 2193 .unwrap_err(); 2194 assert!( 2195 error 2196 .to_string() 2197 .contains("amount must be greater than zero") 2198 ); 2199 2200 let error = validate_transfer_form(TransferForm { 2201 to: "abc".to_string(), 2202 amount: 1, 2203 fee_per_byte: None, 2204 utxos: String::new(), 2205 }) 2206 .unwrap_err(); 2207 assert!(error.to_string().contains("fee per byte is required")); 2208 } 2209 2210 #[test] 2211 fn burn_and_mine_forms_require_fee_per_byte() { 2212 let burn = required_fee_per_byte_burn(&super::BurnSettingsForm { 2213 enabled: Some(true), 2214 amount: 1, 2215 fee_per_byte: None, 2216 }) 2217 .unwrap_err(); 2218 assert!(burn.to_string().contains("fee per byte is required")); 2219 } 2220 2221 #[test] 2222 fn transfer_form_trims_recipient() { 2223 let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { 2224 to: " abc ".to_string(), 2225 amount: 2, 2226 fee_per_byte: Some(3), 2227 utxos: String::new(), 2228 }) 2229 .unwrap(); 2230 2231 assert_eq!(to, "abc"); 2232 assert_eq!(amount, 2); 2233 assert_eq!(fee, 3); 2234 assert!(utxos.is_empty()); 2235 } 2236 2237 #[test] 2238 fn transfer_form_parses_selected_utxos() { 2239 let (_, _, _, utxos) = validate_transfer_form(TransferForm { 2240 to: "abc".to_string(), 2241 amount: 2, 2242 fee_per_byte: Some(3), 2243 utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(), 2244 }) 2245 .unwrap(); 2246 2247 assert_eq!( 2248 utxos, 2249 vec![ 2250 OutPoint { 2251 txid: "tx-one".to_string(), 2252 index: 0 2253 }, 2254 OutPoint { 2255 txid: "tx:with:colons".to_string(), 2256 index: 7 2257 } 2258 ] 2259 ); 2260 }