From 442d63a55e0ceef15bf53f01eb78179effb04431 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 10 Jul 2026 14:29:02 -0400 Subject: [PATCH] feat(rpc): key Tooling Access JWT cache by account (DX-6130) Tooling Access endpoints are provisioned per account, not per API key, so the token cache now keys by account id via a two-level offline lookup: sha256(api_key) -> account_id -> { endpoint_url, token, exp_unix }. All API keys for one account share a single cached JWT. A cache hit resolves the account offline (no control-plane call); a miss learns the account id from account_info once, alongside the mint already occurring. tokens.toml gains a [keys] table (fingerprint -> account id) and per-account [tokens.] entries. save/delete are read-modify-write so one account's token going stale never disturbs another's. An older on-disk schema is a cache miss and is rewritten in place. The custom-endpoint lane is unchanged. Adds config unit tests and updates the rpc integration tests: 137 lib tests plus the rpc suite cover shared-token reuse, zero calls on hit, one account_info on miss, per-account stale-token clear, old-schema miss, and 0600 with no key on disk. --- src/commands/rpc.rs | 79 +++++++++---- src/config.rs | 266 +++++++++++++++++++++++++++++--------------- tests/rpc.rs | 168 ++++++++++++++++++++++++---- 3 files changed, 379 insertions(+), 134 deletions(-) diff --git a/src/commands/rpc.rs b/src/commands/rpc.rs index 924fb28..85a3be2 100644 --- a/src/commands/rpc.rs +++ b/src/commands/rpc.rs @@ -6,7 +6,9 @@ //! refreshes a short-lived session JWT automatically. Because each CLI //! invocation is a fresh process, we persist that JWT to `tokens.toml` and //! re-seed it next time (see `crate::config` token cache), so a valid token -//! means no control-plane round trip. +//! means no control-plane round trip. The cache is keyed by account id (all API +//! keys for one account share a token); the account is resolved offline on a hit +//! and learned via `account_info` only on a miss, where a mint already occurs. //! //! On a never-provisioned account the first call fails with "not enabled"; we //! offer to enable (prompt on a TTY, `--yes` for scripts/agents, otherwise an @@ -104,10 +106,8 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> { let config_path = global.resolve_config_path(); let networks_path = config::networks_cache_path(config_path.as_deref()); let token_path = config::token_cache_path(config_path.as_deref()); - let seed = match (&token_path, resolve_key_quietly(&global)) { - (Some(p), Some(key)) => config::load_token(p, &key), - _ => None, - }; + let (seed, _account_id) = + load_cached_token(&token_path, resolve_key_quietly(&global).as_deref()); let (ctx, _api_key) = Ctx::from_global_with_rpc_seed(global, seed, None)?; let map = ensure_networks(&ctx, networks_path.as_deref()).await?; emit_networks(&ctx, &map) @@ -134,13 +134,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { let token_path = config::token_cache_path(config_path.as_deref()); let networks_path = config::networks_cache_path(config_path.as_deref()); - // We don't know the API key until Ctx builds it, but the cache is keyed by - // the key's fingerprint. Resolve it once here for the load; Ctx re-resolves - // and returns it for the write-back (cheap, and keeps Ctx the source of truth). - let seed = match (&token_path, resolve_key_quietly(&global)) { - (Some(p), Some(key)) => config::load_token(p, &key), - _ => None, - }; + // The cache is keyed by account id, resolved offline from the key's + // fingerprint. Resolve the key once here for the load; Ctx re-resolves and + // returns it for the write-back (cheap, and keeps Ctx the source of truth). + // A known key yields both a seed token and its account id, so a cache hit + // never needs a control-plane account lookup on write-back. + let (seed, mut account_id) = + load_cached_token(&token_path, resolve_key_quietly(&global).as_deref()); let (ctx, api_key) = Ctx::from_global_with_rpc_seed(global, seed, config_endpoint_url)?; @@ -185,10 +185,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { if disabled_per_status(&ctx).await { // The stale token points at the disabled endpoint. Drop it both // in memory (so this retry mints fresh) and on disk (so the next - // process does too) before enabling and retrying. + // process does too) before enabling and retrying. Only this + // account's entry is removed; other accounts' tokens and every + // key mapping in the shared file are preserved. A stale token + // implies a cache hit, so `account_id` is known here. ctx.sdk.rpc.clear_cached_token(); - if let Some(p) = &token_path { - let _ = config::delete_config(p); + if let (Some(p), Some(id)) = (&token_path, account_id) { + let _ = config::delete_account_token(p, id); } maybe_enable(&ctx).await?; call_after_enable(&ctx, method, ¶ms, args.network.clone()).await? @@ -203,13 +206,26 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> { // Snapshot the (possibly refreshed) token and write it back. On the custom-URL // lane no token is minted, so `current_token()` is `None` and nothing is - // written — the existing cache is left untouched. Otherwise we always persist - // when a token is present: the write is an idempotent atomic replace, the - // token is short-lived, and re-writing an unchanged token is harmless. - // Best-effort — a cache write failure must not fail the call, which already - // succeeded; the next run simply re-mints. + // written — the existing cache is left untouched. Otherwise we persist under + // the account id: an idempotent atomic replace preserving other accounts' + // entries. On a cache hit `account_id` is already known (no extra call); on a + // miss (a new key that just minted) we learn it from `account_info()` once, + // which is cheap relative to the mint that just happened. Best-effort — a + // cache write failure must not fail the call, which already succeeded. if let (Some(p), Some(current)) = (&token_path, ctx.sdk.rpc.current_token()) { - let _ = config::save_token(p, &api_key, ¤t); + if account_id.is_none() { + account_id = ctx + .sdk + .admin + .account_info() + .await + .ok() + .and_then(|r| r.data) + .map(|a| a.id); + } + if let Some(id) = account_id { + let _ = config::save_token(p, &api_key, id, ¤t); + } } emit_result(&ctx, &result) @@ -317,6 +333,27 @@ fn emit_networks( Ok(()) } +/// Offline two-level cache load: resolve `key`'s account id from `[keys]`, then +/// load that account's cached JWT. Returns `(seed, account_id)`. The account id +/// is returned even when there's no token yet, so a subsequent write-back can +/// reuse it without a control-plane `account_info` call. A cache miss (unknown +/// key) yields `(None, None)`. +fn load_cached_token( + token_path: &Option, + key: Option<&str>, +) -> (Option, Option) { + let (Some(p), Some(key)) = (token_path, key) else { + return (None, None); + }; + let Some(account_id) = config::account_for_key(p, key) else { + return (None, None); + }; + ( + config::load_token_for_account(p, account_id), + Some(account_id), + ) +} + /// Resolve the API key without prompting, swallowing errors (the real /// resolution + error happens in `Ctx::build`). Used only to scope the cache /// load before `Ctx` is constructed. diff --git a/src/config.rs b/src/config.rs index a859c58..2e2dc45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -231,24 +231,35 @@ fn write_config(path: &Path, cfg: &ConfigFile) -> Result<(), CliError> { // the config (`tokens.toml`) and re-seed the SDK on the next invocation, // avoiding a control-plane round trip while the token is still valid. // -// Only the short-lived JWT is written here — never the long-lived API key. The -// entry is scoped to the account by a fingerprint (SHA-256) of the API key, so -// switching keys transparently invalidates a stale token rather than presenting -// one account's JWT to another's endpoint. +// Tooling Access endpoints are provisioned per account, not per API key, so the +// cache is keyed by account id via a two-level lookup: +// +// sha256(api_key) -> account_id -> { endpoint_url, token, exp_unix } +// +// The `[keys]` table maps an API-key fingerprint to its account id; the +// `[tokens.]` tables hold the JWTs. On a hit, both lookups are +// offline (no control-plane call). All API keys for one account share a single +// cached token. Only the short-lived JWT is written here — never the long-lived +// API key. The account id is a non-secret numeric identifier, stored raw. + +use std::collections::HashMap; use quicknode_sdk::CachedToken; /// On-disk shape of `~/.config/qn/tokens.toml`. #[derive(Debug, Default, Serialize, Deserialize)] pub struct TokenCacheFile { + /// API-key fingerprint (SHA-256 hex) -> account id. + #[serde(default)] + pub keys: HashMap, + /// Account id (as a string, since TOML table keys must be strings) -> cached + /// JWT for that account's Tooling Access endpoint. #[serde(default)] - pub token: Option, + pub tokens: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedTokenEntry { - /// SHA-256 hex of the API key this token was minted for. Never the key. - pub key_hash: String, pub endpoint_url: String, pub token: String, pub exp_unix: i64, @@ -264,23 +275,35 @@ pub fn token_cache_path(config_path: Option<&Path>) -> Option { } } -/// Hex SHA-256 of the API key, used to scope a cached token to its account. +/// Hex SHA-256 of the API key, used to map a key to its account id. pub fn fingerprint_key(api_key: &str) -> String { use sha2::{Digest, Sha256}; let digest = Sha256::digest(api_key.as_bytes()); digest.iter().map(|b| format!("{b:02x}")).collect() } -/// Loads a cached token for `api_key` from `path`. Returns `None` if the file is -/// absent, unparseable, empty, or scoped to a different key (account switch). -/// A malformed cache is treated as a miss, never an error — the SDK will mint. -pub fn load_token(path: &Path, api_key: &str) -> Option { - let text = fs::read_to_string(path).ok()?; - let cache: TokenCacheFile = toml::from_str(&text).ok()?; - let entry = cache.token?; - if entry.key_hash != fingerprint_key(api_key) { - return None; - } +/// Reads `tokens.toml`, treating an absent or malformed file as an empty cache. +/// A malformed cache (e.g. an older on-disk schema) is a miss, never an error. +fn read_cache(path: &Path) -> TokenCacheFile { + fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default() +} + +/// Resolves the account id a known API key maps to, offline. Returns `None` when +/// the key hasn't been seen before (its fingerprint isn't in `[keys]` yet). +pub fn account_for_key(path: &Path, api_key: &str) -> Option { + read_cache(path) + .keys + .get(&fingerprint_key(api_key)) + .copied() +} + +/// Loads the cached token for `account_id`. Returns `None` if the file is +/// absent, unparseable, or has no entry for that account. +pub fn load_token_for_account(path: &Path, account_id: i64) -> Option { + let entry = read_cache(path).tokens.remove(&account_id.to_string())?; Some(CachedToken { endpoint_url: entry.endpoint_url, token: entry.token, @@ -288,77 +311,48 @@ pub fn load_token(path: &Path, api_key: &str) -> Option { }) } -/// Saves `token` to `path` atomically with 0600 perms, scoped to `api_key`. -/// Mirrors [`save_api_key`]'s write discipline: temp file in the same dir, -/// 0600 set before the secret bytes, `rename` over the target. Last-write-wins -/// under concurrency — two `qn rpc` processes may both mint, but the atomic -/// rename guarantees no partial file and both tokens are valid. -pub fn save_token(path: &Path, api_key: &str, token: &CachedToken) -> Result<(), CliError> { - let cache = TokenCacheFile { - token: Some(CachedTokenEntry { - key_hash: fingerprint_key(api_key), +/// Records the `api_key -> account_id` mapping and stores `token` under that +/// account, preserving every other account's entry (read-modify-write). Written +/// atomically with 0600 perms. Last-write-wins under concurrency — two `qn rpc` +/// processes may both mint, but the atomic rename guarantees no partial file. +pub fn save_token( + path: &Path, + api_key: &str, + account_id: i64, + token: &CachedToken, +) -> Result<(), CliError> { + let mut cache = read_cache(path); + cache.keys.insert(fingerprint_key(api_key), account_id); + cache.tokens.insert( + account_id.to_string(), + CachedTokenEntry { endpoint_url: token.endpoint_url.clone(), token: token.token.clone(), exp_unix: token.exp_unix, - }), - }; - let text = toml::to_string_pretty(&cache).map_err(|e| CliError::ConfigWrite { - path: path.to_path_buf(), - source: std::io::Error::other(e), - })?; - - let parent = path.parent().ok_or_else(|| CliError::ConfigWrite { - path: path.to_path_buf(), - source: std::io::Error::other("token cache path has no parent directory"), - })?; - fs::create_dir_all(parent).map_err(|source| CliError::ConfigWrite { - path: path.to_path_buf(), - source, - })?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); - } - - let mut tmp = tempfile::Builder::new() - .prefix(".qn-tokens-") - .tempfile_in(parent) - .map_err(|source| CliError::ConfigWrite { - path: path.to_path_buf(), - source, - })?; + }, + ); + write_cache(path, &cache) +} - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600)).map_err(|source| { - CliError::ConfigWrite { - path: path.to_path_buf(), - source, - } - })?; +/// Drops the cached JWT for `account_id` (e.g. a stale token against an endpoint +/// that was disabled out-of-band), leaving other accounts' tokens and every +/// `[keys]` mapping intact. No-op if there was no entry. Best-effort: a missing +/// or malformed file is not an error. +pub fn delete_account_token(path: &Path, account_id: i64) -> Result<(), CliError> { + let mut cache = read_cache(path); + if cache.tokens.remove(&account_id.to_string()).is_none() { + return Ok(()); } + write_cache(path, &cache) +} - use std::io::Write; - tmp.as_file_mut() - .write_all(text.as_bytes()) - .map_err(|source| CliError::ConfigWrite { - path: path.to_path_buf(), - source, - })?; - tmp.as_file_mut() - .sync_all() - .map_err(|source| CliError::ConfigWrite { - path: path.to_path_buf(), - source, - })?; - tmp.persist(path).map_err(|e| CliError::ConfigWrite { +/// Serializes the cache and writes it atomically at 0600. +fn write_cache(path: &Path, cache: &TokenCacheFile) -> Result<(), CliError> { + let text = toml::to_string_pretty(cache).map_err(|e| CliError::ConfigWrite { path: path.to_path_buf(), - source: e.error, + source: std::io::Error::other(e), })?; - Ok(()) + write_atomic_0600(path, text.as_bytes(), ".qn-tokens-") } // ── Multichain network URL cache ───────────────────────────────────────────── @@ -492,15 +486,6 @@ fn write_atomic_0600(path: &Path, bytes: &[u8], tmp_prefix: &str) -> Result<(), Ok(()) } -/// Deletes the saved config file. No error if it didn't exist. -pub fn delete_config(path: &Path) -> Result<(), CliError> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e.into()), - } -} - /// Resolves an API key per the documented precedence: flag > config file. /// /// `allow_prompt` and `prompt` exist only so `qn auth login` can opt into the @@ -828,4 +813,105 @@ mod tests { "found tempfile leftovers: {leftover:?}" ); } + + // ── token cache ────────────────────────────────────────────────────────── + + fn sample_token(url: &str) -> CachedToken { + CachedToken { + endpoint_url: url.to_string(), + token: "jwt".to_string(), + exp_unix: 4_070_908_800, + } + } + + #[test] + fn token_round_trips_by_account() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + save_token(&path, "key-a", 1, &sample_token("https://a.example/rpc")).unwrap(); + + assert_eq!(account_for_key(&path, "key-a"), Some(1)); + let loaded = load_token_for_account(&path, 1).unwrap(); + assert_eq!(loaded.endpoint_url, "https://a.example/rpc"); + assert_eq!(loaded.token, "jwt"); + } + + #[test] + fn multiple_keys_share_one_account_token() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + let token = sample_token("https://a.example/rpc"); + save_token(&path, "key-a", 1, &token).unwrap(); + // A second key for the same account records its mapping and reuses the + // account's token entry. + save_token(&path, "key-b", 1, &token).unwrap(); + + assert_eq!(account_for_key(&path, "key-a"), Some(1)); + assert_eq!(account_for_key(&path, "key-b"), Some(1)); + assert!(load_token_for_account(&path, 1).is_some()); + } + + #[test] + fn save_preserves_other_accounts() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + save_token(&path, "key-a", 1, &sample_token("https://a.example/rpc")).unwrap(); + save_token(&path, "key-b", 2, &sample_token("https://b.example/rpc")).unwrap(); + + assert!(load_token_for_account(&path, 1).is_some()); + assert!(load_token_for_account(&path, 2).is_some()); + assert_eq!(account_for_key(&path, "key-a"), Some(1)); + assert_eq!(account_for_key(&path, "key-b"), Some(2)); + } + + #[test] + fn delete_account_token_leaves_other_entries() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + save_token(&path, "key-a", 1, &sample_token("https://a.example/rpc")).unwrap(); + save_token(&path, "key-b", 2, &sample_token("https://b.example/rpc")).unwrap(); + + delete_account_token(&path, 1).unwrap(); + + // Account 1's token is gone, but its key mapping and account 2 remain. + assert!(load_token_for_account(&path, 1).is_none()); + assert_eq!(account_for_key(&path, "key-a"), Some(1)); + assert!(load_token_for_account(&path, 2).is_some()); + } + + #[test] + fn delete_account_token_missing_file_is_ok() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + assert!(delete_account_token(&path, 1).is_ok()); + } + + #[test] + fn old_schema_is_treated_as_a_miss() { + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + // The previous single-entry `[token]` schema keyed by `key_hash`. + let old = "[token]\nkey_hash = \"deadbeef\"\nendpoint_url = \"https://x/rpc\"\n\ + token = \"seeded.jwt\"\nexp_unix = 4070908800\n"; + fs::write(&path, old).unwrap(); + + // No account resolvable, no token loadable, and no error. + assert_eq!(account_for_key(&path, "anything"), None); + assert!(load_token_for_account(&path, 1).is_none()); + + // The next write rewrites the file in the new shape. + save_token(&path, "key-a", 1, &sample_token("https://a.example/rpc")).unwrap(); + assert_eq!(account_for_key(&path, "key-a"), Some(1)); + } + + #[cfg(unix)] + #[test] + fn save_token_writes_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempdir().unwrap(); + let path = dir.path().join("tokens.toml"); + save_token(&path, "key-a", 1, &sample_token("https://a.example/rpc")).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected 0600, got {mode:o}"); + } } diff --git a/tests/rpc.rs b/tests/rpc.rs index 6d1ccb7..0c5758a 100644 --- a/tests/rpc.rs +++ b/tests/rpc.rs @@ -1,8 +1,9 @@ //! Integration tests for `qn rpc`. //! //! The in-process harness injects `--api-key test`; the token cache is keyed by -//! the SHA-256 of that key. Tests that exercise the cache pass `--config-file` -//! so `tokens.toml` lands in a tempdir rather than the real home. +//! account id via a `sha256(key) -> account_id -> token` lookup. Tests that +//! exercise the cache pass `--config-file` so `tokens.toml` lands in a tempdir +//! rather than the real home. mod common; @@ -15,20 +16,46 @@ use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; // SHA-256 of "test" (the harness-injected API key). const TEST_KEY_HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; +// The account id used across the cache tests. +const TEST_ACCOUNT_ID: i64 = 12345; + // A far-future ISO timestamp so a freshly minted token is never near expiry. const FUTURE_ISO: &str = "2099-01-01T00:00:00.000Z"; // Matching far-future unix seconds for a seeded token. const FUTURE_UNIX: i64 = 4_070_908_800; -fn write_token_cache(dir: &tempfile::TempDir, endpoint_url: &str, key_hash: &str) { +/// Writes a `tokens.toml` in the new two-level shape: `key_hash -> account_id` +/// and `account_id -> token`. A hit on `key_hash` resolves the account offline. +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!( - "[token]\nkey_hash = \"{key_hash}\"\nendpoint_url = \"{endpoint_url}\"\n\ + "[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(); } +/// Mounts a `GET /v0/account/info` returning `TEST_ACCOUNT_ID`, asserting it is +/// called exactly `times`. Cache misses need it (to key the write-back by +/// account); cache hits must never call it. +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 { // Provide an API key via the config file so the cache parent dir is the // tempdir. The harness still injects --api-key test, which wins, so the @@ -52,10 +79,17 @@ async fn seeded_token_skips_mint() { .expect(1) .mount(&server) .await; + // A cache hit must resolve the account offline: account_info is never called. + 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); + write_token_cache( + &dir, + &format!("{}/rpc", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -81,7 +115,12 @@ async fn already_enabled_yes_does_not_wait() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + write_token_cache( + &dir, + &format!("{}/rpc", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let started = std::time::Instant::now(); let out = run_qn( @@ -129,6 +168,8 @@ async fn no_cache_mints_then_calls() { .expect(1) .mount(&server) .await; + // A miss (unknown key) learns the account id once to key the write-back. + mount_account_info(&server, 1).await; let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); @@ -139,9 +180,22 @@ async fn no_cache_mints_then_calls() { .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); - // The minted token should have been written back to the cache. + // The minted token should have been written back under the account id, with + // the key -> account mapping recorded, and never the API key itself. 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] @@ -158,7 +212,12 @@ async fn json_rpc_error_exits_nonzero() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + write_token_cache( + &dir, + &format!("{}/rpc", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -275,9 +334,11 @@ async fn not_enabled_with_yes_auto_enables_and_retries() { } #[tokio::test] -async fn account_switch_invalidates_cached_token() { +async fn unknown_key_mints_and_preserves_other_account() { let server = MockServer::start().await; - // A cache entry scoped to a DIFFERENT key. The SDK must ignore it and mint. + // The cache holds a token for a DIFFERENT key/account. Our key isn't in + // `[keys]`, so this is a miss: mint, learn our account id, and store under it + // without disturbing the other account's entry. Mock::given(method("POST")) .and(path("/v0/tooling-access/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -298,11 +359,14 @@ async fn account_switch_invalidates_cached_token() { }))) .mount(&server) .await; + // A miss learns our account id once. + mount_account_info(&server, 1).await; let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - // Cache scoped to some other account's key hash. - write_token_cache(&dir, &format!("{}/rpc", server.uri()), "deadbeef"); + // Pre-seed a different account (id 999) under a different key hash. + let other_id = 999; + write_token_cache(&dir, &format!("{}/rpc", server.uri()), "deadbeef", other_id); let out = run_qn( &server.uri(), @@ -310,6 +374,18 @@ async fn account_switch_invalidates_cached_token() { ) .await; assert_eq!(out.exit_code, 0, "stderr={}", out.stderr); + + // Both accounts now coexist in the shared file: ours (freshly minted) and + // the pre-existing one (untouched). + 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}" + ); } // A cached token pointing at an unreachable (disabled) endpoint yields a @@ -335,7 +411,12 @@ async fn connect_failure_with_disabled_status_prompts_to_enable() { // Seed a still-valid token whose endpoint_url refuses connections fast // (loopback, almost-certainly-closed high port) so the RPC POST returns a // connect error promptly rather than waiting out the timeout. - write_token_cache(&dir, "http://127.0.0.1:9/rpc", TEST_KEY_HASH); + write_token_cache( + &dir, + "http://127.0.0.1:9/rpc", + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -357,10 +438,16 @@ async fn connect_failure_with_disabled_status_prompts_to_enable() { "expected enable guidance, got: {}", out.stderr ); - // The stale token cache should have been cleared before the enable attempt. + // The stale token entry for this account should have been dropped before the + // enable attempt, while the file (and its key -> account mapping) remains. + let cached = std::fs::read_to_string(dir.path().join("tokens.toml")).unwrap(); assert!( - !dir.path().join("tokens.toml").exists(), - "stale token cache should be cleared" + !cached.contains(&format!("[tokens.{TEST_ACCOUNT_ID}]")), + "stale account token should be cleared: {cached}" + ); + assert!( + cached.contains(TEST_KEY_HASH), + "key mapping preserved: {cached}" ); } @@ -408,7 +495,12 @@ async fn connect_failure_with_disabled_status_auto_enables_with_yes() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, "http://127.0.0.1:9/rpc", TEST_KEY_HASH); + write_token_cache( + &dir, + "http://127.0.0.1:9/rpc", + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -468,7 +560,12 @@ async fn network_routes_to_mapped_url() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + write_token_cache( + &dir, + &format!("{}/default", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -519,7 +616,12 @@ async fn list_networks_prints_keys() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + write_token_cache( + &dir, + &format!("{}/default", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -558,7 +660,12 @@ async fn unknown_network_errors() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/default", server.uri()), TEST_KEY_HASH); + write_token_cache( + &dir, + &format!("{}/default", server.uri()), + TEST_KEY_HASH, + TEST_ACCOUNT_ID, + ); let out = run_qn( &server.uri(), @@ -836,7 +943,12 @@ async fn params_file_reads_json_from_file() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + 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(); @@ -870,7 +982,12 @@ async fn params_file_missing_path_errors() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + 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( @@ -908,7 +1025,12 @@ async fn params_positional_and_file_conflict() { let dir = tempfile::tempdir().unwrap(); let cfg = cfg_path(&dir); - write_token_cache(&dir, &format!("{}/rpc", server.uri()), TEST_KEY_HASH); + 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();