mod common;
use common::run_qn;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const TEST_KEY_HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
const TEST_ACCOUNT_ID: i64 = 12345;
const FUTURE_ISO: &str = "2099-01-01T00:00:00.000Z";
const FUTURE_UNIX: i64 = 4_070_908_800;
fn write_token_cache(dir: &tempfile::TempDir, endpoint_url: &str, key_hash: &str, account_id: i64) {
let path = dir.path().join("tokens.toml");
let body = format!(
"[keys]\n\"{key_hash}\" = {account_id}\n\n\
[tokens.{account_id}]\nendpoint_url = \"{endpoint_url}\"\n\
token = \"seeded.jwt\"\nexp_unix = {FUTURE_UNIX}\n"
);
std::fs::write(&path, body).unwrap();
}
async fn mount_account_info(server: &MockServer, times: u64) {
Mock::given(method("GET"))
.and(path("/v0/account/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"id": TEST_ACCOUNT_ID,
"name": "Acme Inc",
"created_at": "2024-01-01T00:00:00Z",
"billing_version": "v6",
"subscription": null
}
})))
.expect(times)
.mount(server)
.await;
}
fn cfg_path(dir: &tempfile::TempDir) -> String {
let path = dir.path().join("config.toml");
std::fs::write(&path, "[api]\nkey = \"test\"\n").unwrap();
path.to_str().unwrap().to_string()
}
#[tokio::test]
async fn seeded_token_skips_mint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.and(body_partial_json(json!({ "method": "eth_blockNumber" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1335f9a"
})))
.expect(1)
.mount(&server)
.await;
mount_account_info(&server, 0).await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&["--config-file", &cfg, "rpc", "call", "eth_blockNumber"],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn already_enabled_yes_does_not_wait() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1"
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let started = std::time::Instant::now();
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--yes",
],
)
.await;
let elapsed = started.elapsed();
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
assert!(
elapsed < std::time::Duration::from_millis(500),
"already-enabled --yes should not wait, took {elapsed:?}"
);
}
#[tokio::test]
async fn no_cache_mints_then_calls() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v0/tooling-access/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"endpoint_url": format!("{}/rpc", server.uri()),
"token": "minted.jwt",
"expires_at": FUTURE_ISO
},
"error": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0xabc"
})))
.expect(1)
.mount(&server)
.await;
mount_account_info(&server, 1).await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&["--config-file", &cfg, "rpc", "call", "eth_blockNumber"],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
let cached = std::fs::read_to_string(dir.path().join("tokens.toml")).unwrap();
assert!(cached.contains("minted.jwt"), "cache: {cached}");
assert!(
cached.contains(&format!("[tokens.{TEST_ACCOUNT_ID}]")),
"token keyed by account id: {cached}"
);
assert!(
cached.contains(TEST_KEY_HASH),
"key mapping recorded: {cached}"
);
assert!(
!cached.contains("\"test\""),
"api key must not be stored: {cached}"
);
}
#[tokio::test]
async fn json_rpc_error_exits_nonzero() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1,
"error": { "code": -32602, "message": "invalid params" }
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_getBalance",
"[\"bad\"]",
],
)
.await;
assert_eq!(out.exit_code, 1, "stderr={}", out.stderr);
}
#[tokio::test]
async fn not_enabled_without_yes_is_actionable_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v0/tooling-access/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"data": null,
"error": "Tooling access is not enabled. Enable it first."
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&["--config-file", &cfg, "rpc", "call", "eth_blockNumber"],
)
.await;
assert_eq!(out.exit_code, 1, "stderr={}", out.stderr);
assert!(
out.stderr.contains("tooling-access enable"),
"stderr={}",
out.stderr
);
}
#[tokio::test]
async fn not_enabled_with_yes_auto_enables_and_retries() {
let server = MockServer::start().await;
struct MintSeq {
calls: AtomicUsize,
url: String,
}
impl Respond for MintSeq {
fn respond(&self, _: &Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
ResponseTemplate::new(400).set_body_json(json!({
"data": null,
"error": "Tooling access is not enabled. Enable it first."
}))
} else {
ResponseTemplate::new(200).set_body_json(json!({
"data": { "endpoint_url": self.url, "token": "minted.jwt", "expires_at": FUTURE_ISO },
"error": null
}))
}
}
}
Mock::given(method("POST"))
.and(path("/v0/tooling-access/token"))
.respond_with(MintSeq {
calls: AtomicUsize::new(0),
url: format!("{}/rpc", server.uri()),
})
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/rpc", server.uri()) },
"error": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0xok"
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--yes",
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn unknown_key_mints_and_preserves_other_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v0/tooling-access/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"endpoint_url": format!("{}/rpc", server.uri()),
"token": "minted.jwt",
"expires_at": FUTURE_ISO
},
"error": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0xok"
})))
.mount(&server)
.await;
mount_account_info(&server, 1).await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let other_id = 999;
write_token_cache(&dir, &format!("{}/rpc", server.uri()), "deadbeef", other_id);
let out = run_qn(
&server.uri(),
&["--config-file", &cfg, "rpc", "call", "eth_blockNumber"],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
let cached = std::fs::read_to_string(dir.path().join("tokens.toml")).unwrap();
assert!(
cached.contains(&format!("[tokens.{TEST_ACCOUNT_ID}]")),
"our account cached: {cached}"
);
assert!(
cached.contains(&format!("[tokens.{other_id}]")),
"other account preserved: {cached}"
);
}
#[tokio::test]
async fn connect_failure_with_disabled_status_prompts_to_enable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": 3 },
"error": null
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
"http://127.0.0.1:9/rpc",
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--retries",
"0",
],
)
.await;
assert_ne!(out.exit_code, 0, "should fail");
assert!(
out.stderr.contains("tooling-access enable"),
"expected enable guidance, got: {}",
out.stderr
);
let cached = std::fs::read_to_string(dir.path().join("tokens.toml")).unwrap();
assert!(
!cached.contains(&format!("[tokens.{TEST_ACCOUNT_ID}]")),
"stale account token should be cleared: {cached}"
);
assert!(
cached.contains(TEST_KEY_HASH),
"key mapping preserved: {cached}"
);
}
#[tokio::test]
async fn connect_failure_with_disabled_status_auto_enables_with_yes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": 3 },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/rpc", server.uri()), "endpoint_id": 3 },
"error": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v0/tooling-access/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "endpoint_url": format!("{}/rpc", server.uri()), "token": "minted.jwt", "expires_at": FUTURE_ISO },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0xok"
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
"http://127.0.0.1:9/rpc",
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--retries",
"0",
"--yes",
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn network_routes_to_mapped_url() {
let server = MockServer::start().await;
let solana_url = format!("{}/solana", server.uri());
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/endpoints/3/urls"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"http_url": format!("{}/default", server.uri()),
"wss_url": null,
"multichain_urls": {
"solana-mainnet": { "http_url": solana_url, "wss_url": null }
}
},
"error": null
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/solana"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "12345"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/default", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"getSlot",
"--network",
"solana-mainnet",
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
let cached = std::fs::read_to_string(dir.path().join("networks.toml")).unwrap();
assert!(cached.contains("solana-mainnet"), "cache: {cached}");
}
#[tokio::test]
async fn network_when_disabled_without_yes_prompts_to_enable() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": null },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/endpoints/3/urls"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"getSlot",
"--network",
"solana-mainnet",
],
)
.await;
assert_ne!(out.exit_code, 0, "should fail");
assert!(
out.stderr.contains("tooling-access enable"),
"expected enable hint, got: {}",
out.stderr
);
}
#[tokio::test]
async fn network_when_disabled_with_yes_enables_and_routes() {
let server = MockServer::start().await;
let solana_url = format!("{}/solana", server.uri());
struct StatusSeq {
calls: AtomicUsize,
default_url: String,
}
impl wiremock::Respond for StatusSeq {
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": null },
"error": null
}))
} else {
ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": self.default_url, "endpoint_id": 3 },
"error": null
}))
}
}
}
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(StatusSeq {
calls: AtomicUsize::new(0),
default_url: format!("{}/default", server.uri()),
})
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()) },
"error": null
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/endpoints/3/urls"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"http_url": format!("{}/default", server.uri()),
"wss_url": null,
"multichain_urls": {
"solana-mainnet": { "http_url": solana_url, "wss_url": null }
}
},
"error": null
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/solana"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "12345"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/default", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"getSlot",
"--network",
"solana-mainnet",
"--yes",
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn list_networks_prints_keys() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/endpoints/3/urls"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"http_url": format!("{}/default", server.uri()),
"multichain_urls": {
"solana-mainnet": { "http_url": "https://x/sol", "wss_url": null },
"polygon": { "http_url": "https://x/matic", "wss_url": null }
}
},
"error": null
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/default", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&["--config-file", &cfg, "rpc", "list-networks"],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn unknown_network_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()), "endpoint_id": 3 },
"error": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/endpoints/3/urls"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": {
"http_url": format!("{}/default", server.uri()),
"multichain_urls": {
"solana-mainnet": { "http_url": "https://x/sol", "wss_url": null }
}
},
"error": null
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/default", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"getSlot",
"--network",
"nope-mainnet",
],
)
.await;
assert_ne!(out.exit_code, 0, "should fail on unknown network");
assert!(
out.stderr.contains("qn rpc list-networks"),
"expected list-networks hint, got: {}",
out.stderr
);
assert!(
out.stderr.contains("solana-mainnet"),
"expected available keys, got: {}",
out.stderr
);
}
#[tokio::test]
async fn endpoint_url_flag_routes_call_without_minting() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/custom/rpc"))
.and(body_partial_json(json!({ "method": "eth_blockNumber" })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0xcafe"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let url = format!("{}/custom/rpc", server.uri());
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--endpoint-url",
&url,
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
assert!(
!dir.path().join("tokens.toml").exists(),
"custom-URL call must not write a token cache"
);
}
#[tokio::test]
async fn config_endpoint_url_routes_call() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/from-config/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let url = format!("{}/from-config/rpc", server.uri());
std::fs::write(
&path,
format!("[api]\nkey = \"test\"\n\n[rpc]\nendpoint_url = \"{url}\"\n"),
)
.unwrap();
let cfg = path.to_str().unwrap();
let out = run_qn(
&server.uri(),
&["--config-file", cfg, "rpc", "call", "eth_blockNumber"],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn flag_endpoint_url_overrides_config() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/flag/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x1"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg_url = format!("{}/config/rpc", server.uri());
std::fs::write(
&path,
format!("[api]\nkey = \"test\"\n\n[rpc]\nendpoint_url = \"{cfg_url}\"\n"),
)
.unwrap();
let cfg = path.to_str().unwrap();
let flag_url = format!("{}/flag/rpc", server.uri());
let out = run_qn(
&server.uri(),
&[
"--config-file",
cfg,
"rpc",
"call",
"eth_blockNumber",
"--endpoint-url",
&flag_url,
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn custom_url_failure_bypasses_enable_recovery() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/custom/rpc"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1,
"error": { "code": -32601, "message": "method not found" }
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let url = format!("{}/custom/rpc", server.uri());
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--endpoint-url",
&url,
],
)
.await;
assert_ne!(out.exit_code, 0, "custom-URL JSON-RPC error should fail");
}
#[tokio::test]
async fn endpoint_url_rejects_bad_scheme() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--endpoint-url",
"ftp://example.com/rpc",
],
)
.await;
assert_ne!(out.exit_code, 0, "bad scheme should be rejected");
assert!(
out.stderr.contains("http or https"),
"stderr={}",
out.stderr
);
}
#[tokio::test]
async fn endpoint_url_conflicts_with_network() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v0/tooling-access"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_blockNumber",
"--endpoint-url",
"https://x/rpc",
"--network",
"solana-mainnet",
],
)
.await;
assert_ne!(out.exit_code, 0, "conflicting flags should fail to parse");
}
#[tokio::test]
async fn params_file_reads_json_from_file() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.and(body_partial_json(json!({
"method": "eth_getBalance",
"params": ["0xabc", "latest"]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": 1, "result": "0x0"
})))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let params_path = dir.path().join("params.json");
std::fs::write(¶ms_path, r#"["0xabc", "latest"]"#).unwrap();
let params_file = params_path.to_str().unwrap();
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_getBalance",
"--params-file",
params_file,
],
)
.await;
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
}
#[tokio::test]
async fn params_file_missing_path_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let missing = dir.path().join("does-not-exist.json");
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_getBalance",
"-f",
missing.to_str().unwrap(),
],
)
.await;
assert_ne!(out.exit_code, 0, "missing params file should fail");
assert!(
out.stderr.contains("could not read params file"),
"stderr={}",
out.stderr
);
}
#[tokio::test]
async fn params_positional_and_file_conflict() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rpc"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_path(&dir);
write_token_cache(
&dir,
&format!("{}/rpc", server.uri()),
TEST_KEY_HASH,
TEST_ACCOUNT_ID,
);
let params_path = dir.path().join("params.json");
std::fs::write(¶ms_path, "[]").unwrap();
let out = run_qn(
&server.uri(),
&[
"--config-file",
&cfg,
"rpc",
"call",
"eth_getBalance",
"[\"inline\"]",
"--params-file",
params_path.to_str().unwrap(),
],
)
.await;
assert_ne!(
out.exit_code, 0,
"positional + --params-file should conflict"
);
}