//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. use onionwire::wallet::{self, Wallet}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; fn json_rpc_ok(result: &str) -> String { let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#); format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ) } #[test] fn disabled_create_address_is_not_configured() { let w = Wallet::disabled(); let err = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(w.create_address()) .unwrap_err(); assert!(err.to_string().contains("not configured"), "got {err}"); } #[test] fn refuse_url_without_credentials() { let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err(); let msg = err.to_string().to_ascii_lowercase(); assert!( msg.contains("credential") || msg.contains("login") || msg.contains("user"), "got {err}" ); } #[test] fn refuse_non_loopback_non_onion_host() { let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err(); assert!( err.to_string().to_ascii_lowercase().contains("host") || err.to_string().contains("loopback") || err.to_string().contains("onion"), "got {err}" ); } #[test] fn refuse_onion_rpc_url() { let err = Wallet::from_url( "http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083", ) .unwrap_err(); let msg = err.to_string(); assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}"); assert!( !msg.contains("s3cretPASS"), "password leaked in error: {err}" ); } #[test] fn password_absent_from_url_errors() { let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err(); assert!( !err.to_string().contains("s3cretPASS"), "password leaked in error: {err}" ); let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err(); assert!( !err.to_string().contains("s3cretPASS"), "password leaked in error: {err}" ); } #[tokio::test] async fn mock_get_address_parses_string() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let canned = json_rpc_ok( r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, ); tokio::spawn(serve_digest_then(listener, canned)); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let got = w.get_address().await.expect("get_address"); assert!(got.starts_with('4'), "got {got}"); } #[tokio::test] async fn mock_create_address_and_transfer() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let canned = json_rpc_ok( r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#, ); tokio::spawn(serve_digest_then(listener, canned.clone())); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let created = w.create_address().await.expect("create_address"); assert!(created.starts_with('8')); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(serve_digest_then(listener, canned)); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let txid = w .transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1) .await .expect("transfer"); assert_eq!(txid, "abc123"); } #[tokio::test] async fn mock_get_transfers_matches_txid() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let canned = json_rpc_ok( r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#, ); tokio::spawn(serve_digest_then(listener, canned)); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let rows = w.get_transfers().await.expect("get_transfers"); assert!(wallet::transfers_match( &rows, "deadbeef", "5", "8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" )); assert!(!wallet::transfers_match(&rows, "nope", "1", "nope")); } #[tokio::test] async fn oversized_rpc_response_is_err() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let huge = vec![b'A'; 2 * 1024 * 1024]; tokio::spawn(async move { let (mut sock, _) = listener.accept().await.expect("accept"); let mut buf = vec![0u8; 4096]; let _ = sock.read(&mut buf).await; sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n") .await .expect("hdr"); sock.write_all(&huge).await.expect("body"); }); let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap(); let err = w.get_address().await.unwrap_err(); let msg = err.to_string().to_ascii_lowercase(); assert!( msg.contains("large") || msg.contains("size") || msg.contains("cap"), "got {err}" ); assert!( !err.to_string().contains("secret"), "password leaked: {err}" ); } fn digest_401() -> String { "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into() } async fn serve_digest_then(listener: TcpListener, ok: String) { let (mut sock, _) = listener.accept().await.expect("accept"); let mut buf = vec![0u8; 8192]; let _ = sock.read(&mut buf).await; sock.write_all(digest_401().as_bytes()).await.expect("401"); drop(sock); let (mut sock, _) = listener.accept().await.expect("accept2"); buf.fill(0); let n = sock.read(&mut buf).await.unwrap_or(0); let req = String::from_utf8_lossy(&buf[..n]); assert!( req.contains("Authorization: Digest"), "missing digest auth: {req}" ); assert!(req.contains("username=\"ow\""), "missing user: {req}"); assert!(req.contains("response=\""), "missing response: {req}"); sock.write_all(ok.as_bytes()).await.expect("200"); } #[tokio::test] async fn mock_digest_auth_accepted() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let canned = json_rpc_ok( r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#, ); tokio::spawn(serve_digest_then(listener, canned)); let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap(); let got = w.get_address().await.expect("get_address"); assert!(got.starts_with('4'), "got {got}"); }