Skip to content

Commit a981b5b

Browse files
authored
fix(rpc): route --network through the enable prompt when Tooling Access is off (DX-6132) (#55)
The --network lane resolved the per-network URL map in ensure_networks before reaching the enable-recovery flow, so an account that hadn't enabled Tooling Access hit a hard 'endpoint did not report an id' error instead of the enable gate the default lane offers. ensure_networks now checks status.enabled and, when off, routes through the existing maybe_enable gate (TTY prompt / --yes auto-enable / actionable error), re-fetches status, then requires the endpoint id. The 'did not report an id' error is reserved for the provisioned-but-idless case. Reuses maybe_enable verbatim so gating matches the default lane. Adds two tests: disabled + non-TTY without --yes fails with the enable hint and makes zero enable/URL-fetch/RPC calls; disabled + --yes enables, re-fetches, and routes to the mapped URL.
1 parent 0dc0356 commit a981b5b

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

‎src/commands/rpc.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,17 @@ async fn ensure_networks(
282282
networks_path: Option<&std::path::Path>,
283283
) -> Result<std::collections::HashMap<String, String>, CliError> {
284284
// Need the endpoint id to scope the cache and fetch URLs.
285-
let status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?;
285+
let mut status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?;
286+
287+
// Not enabled yet: this is the same "offer to enable" decision the default
288+
// lane makes on a mint 400 — prompt on a TTY, auto on --yes, actionable
289+
// exit-5 error otherwise. `maybe_enable` returns Cancelled if the user
290+
// declines, so we only re-fetch after a successful enable.
291+
if !status.enabled {
292+
maybe_enable(ctx).await?;
293+
status = retrying(ctx.global.retries, || ctx.sdk.admin.tooling_access_status()).await?;
294+
}
295+
286296
let Some(endpoint_id) = status.endpoint_id else {
287297
return Err(CliError::Arg(
288298
"this account's Tooling Access endpoint did not report an id, so per-network \

‎tests/rpc.rs‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,156 @@ async fn network_routes_to_mapped_url() {
587587
assert!(cached.contains("solana-mainnet"), "cache: {cached}");
588588
}
589589

590+
// --network when Tooling Access is disabled routes through the enable gate.
591+
// In the harness (non-TTY + --no-input) without --yes that gate can't prompt,
592+
// so it ends in the actionable "run qn tooling-access enable" error (exit 5)
593+
// and no URL-map fetch or RPC call is made.
594+
#[tokio::test]
595+
async fn network_when_disabled_without_yes_prompts_to_enable() {
596+
let server = MockServer::start().await;
597+
Mock::given(method("GET"))
598+
.and(path("/v0/tooling-access"))
599+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
600+
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": null },
601+
"error": null
602+
})))
603+
.mount(&server)
604+
.await;
605+
// No enable, no URL-map fetch, no RPC call should be reached.
606+
Mock::given(method("PATCH"))
607+
.and(path("/v0/tooling-access"))
608+
.respond_with(ResponseTemplate::new(200))
609+
.expect(0)
610+
.mount(&server)
611+
.await;
612+
Mock::given(method("GET"))
613+
.and(path("/v0/endpoints/3/urls"))
614+
.respond_with(ResponseTemplate::new(200))
615+
.expect(0)
616+
.mount(&server)
617+
.await;
618+
619+
let dir = tempfile::tempdir().unwrap();
620+
let cfg = cfg_path(&dir);
621+
let out = run_qn(
622+
&server.uri(),
623+
&[
624+
"--config-file",
625+
&cfg,
626+
"rpc",
627+
"call",
628+
"getSlot",
629+
"--network",
630+
"solana-mainnet",
631+
],
632+
)
633+
.await;
634+
assert_ne!(out.exit_code, 0, "should fail");
635+
// Same actionable message as the default lane's mint-400 path.
636+
assert!(
637+
out.stderr.contains("tooling-access enable"),
638+
"expected enable hint, got: {}",
639+
out.stderr
640+
);
641+
}
642+
643+
// --network when disabled + --yes: enable, re-fetch status (now enabled with an
644+
// id), fetch the per-network map, and route the call to the mapped URL.
645+
#[tokio::test]
646+
async fn network_when_disabled_with_yes_enables_and_routes() {
647+
let server = MockServer::start().await;
648+
let solana_url = format!("{}/solana", server.uri());
649+
650+
// Status flips to enabled after the enable PATCH: first GET reports disabled,
651+
// subsequent GETs report enabled with an id.
652+
struct StatusSeq {
653+
calls: AtomicUsize,
654+
default_url: String,
655+
}
656+
impl wiremock::Respond for StatusSeq {
657+
fn respond(&self, _: &wiremock::Request) -> ResponseTemplate {
658+
let n = self.calls.fetch_add(1, Ordering::SeqCst);
659+
if n == 0 {
660+
ResponseTemplate::new(200).set_body_json(json!({
661+
"data": { "enabled": false, "endpoint_url": null, "endpoint_id": null },
662+
"error": null
663+
}))
664+
} else {
665+
ResponseTemplate::new(200).set_body_json(json!({
666+
"data": { "enabled": true, "endpoint_url": self.default_url, "endpoint_id": 3 },
667+
"error": null
668+
}))
669+
}
670+
}
671+
}
672+
Mock::given(method("GET"))
673+
.and(path("/v0/tooling-access"))
674+
.respond_with(StatusSeq {
675+
calls: AtomicUsize::new(0),
676+
default_url: format!("{}/default", server.uri()),
677+
})
678+
.mount(&server)
679+
.await;
680+
Mock::given(method("PATCH"))
681+
.and(path("/v0/tooling-access"))
682+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
683+
"data": { "enabled": true, "endpoint_url": format!("{}/default", server.uri()) },
684+
"error": null
685+
})))
686+
.expect(1)
687+
.mount(&server)
688+
.await;
689+
Mock::given(method("GET"))
690+
.and(path("/v0/endpoints/3/urls"))
691+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
692+
"data": {
693+
"http_url": format!("{}/default", server.uri()),
694+
"wss_url": null,
695+
"multichain_urls": {
696+
"solana-mainnet": { "http_url": solana_url, "wss_url": null }
697+
}
698+
},
699+
"error": null
700+
})))
701+
.mount(&server)
702+
.await;
703+
Mock::given(method("POST"))
704+
.and(path("/solana"))
705+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
706+
"jsonrpc": "2.0", "id": 1, "result": "12345"
707+
})))
708+
.expect(1)
709+
.mount(&server)
710+
.await;
711+
712+
let dir = tempfile::tempdir().unwrap();
713+
let cfg = cfg_path(&dir);
714+
// Seed a cached token so the call uses it rather than minting (the enable
715+
// flow, not token acquisition, is what this test exercises).
716+
write_token_cache(
717+
&dir,
718+
&format!("{}/default", server.uri()),
719+
TEST_KEY_HASH,
720+
TEST_ACCOUNT_ID,
721+
);
722+
723+
let out = run_qn(
724+
&server.uri(),
725+
&[
726+
"--config-file",
727+
&cfg,
728+
"rpc",
729+
"call",
730+
"getSlot",
731+
"--network",
732+
"solana-mainnet",
733+
"--yes",
734+
],
735+
)
736+
.await;
737+
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
738+
}
739+
590740
// --list-networks prints the available keys without making an RPC call.
591741
#[tokio::test]
592742
async fn list_networks_prints_keys() {

0 commit comments

Comments
 (0)