onionwire/tests/wallet.rs

194 lines
7.3 KiB
Rust
Raw Normal View History

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