Test token caching and StaticToken

Client tests use a counting TokenSource to check that a fresh token is served from the cache, and that a token whose lifetime falls inside the refresh margin is minted again on the next request. They also check that Client::new accepts both a StaticToken and a Box<dyn TokenSource>. token.rs tests cover StaticToken's default and overridden lifetimes, and check that Debug output for StaticToken, MintedToken and dyn TokenSource never contains the token value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 8, 2026 at 23:36 UTC 260ffbe32e2462345fcb24d310f22a792abd7dbd
2 files changed +100
crates/core/src/client.rs
+54
@@ -270,6 +270,60 @@ fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
270 #[cfg(test)]
271 mod tests {
272 use super::*;
273 + use crate::token::{MintedToken, StaticToken};
274 + use std::sync::atomic::{AtomicUsize, Ordering};
275 + use std::sync::Arc;
276 +
277 + /// Counts mints so the tests can see when the client goes back to the
278 + /// source instead of its cache.
279 + struct CountingSource {
280 + mints: Arc<AtomicUsize>,
281 + lifetime_secs: u64,
282 + }
283 +
284 + impl TokenSource for CountingSource {
285 + fn mint(&self) -> Result<MintedToken> {
286 + let n = self.mints.fetch_add(1, Ordering::SeqCst) + 1;
287 + Ok(MintedToken::new(format!("token-{n}"), self.lifetime_secs))
288 + }
289 + }
290 +
291 + fn counting_client(lifetime_secs: u64) -> (Client, Arc<AtomicUsize>) {
292 + let mints = Arc::new(AtomicUsize::new(0));
293 + let client = Client::new(CountingSource {
294 + mints: mints.clone(),
295 + lifetime_secs,
296 + });
297 + (client, mints)
298 + }
299 +
300 + #[test]
301 + fn a_fresh_token_is_reused_from_the_cache() {
302 + let (client, mints) = counting_client(20 * 60);
303 + assert_eq!(client.bearer_token().unwrap(), "token-1");
304 + assert_eq!(client.bearer_token().unwrap(), "token-1");
305 + assert_eq!(mints.load(Ordering::SeqCst), 1);
306 + }
307 +
308 + /// A lifetime inside the refresh margin is already "about to expire",
309 + /// so every request mints again rather than sending a token Apple may
310 + /// reject mid-flight.
311 + #[test]
312 + fn a_token_inside_the_refresh_margin_is_minted_again() {
313 + let (client, mints) = counting_client(TOKEN_REFRESH_MARGIN.as_secs());
314 + assert_eq!(client.bearer_token().unwrap(), "token-1");
315 + assert_eq!(client.bearer_token().unwrap(), "token-2");
316 + assert_eq!(mints.load(Ordering::SeqCst), 2);
317 + }
318 +
319 + #[test]
320 + fn new_accepts_a_static_token_and_a_boxed_source() {
321 + let client = Client::new(StaticToken::new("eyJ.static"));
322 + assert_eq!(client.bearer_token().unwrap(), "eyJ.static");
323 +
324 + let boxed: Box<dyn TokenSource> = Box::new(StaticToken::new("eyJ.boxed"));
325 + assert_eq!(Client::new(boxed).bearer_token().unwrap(), "eyJ.boxed");
326 + }
327
328 // `links.next` is the whole paging decision, so it is pinned against the
329 // URL shape App Store Connect actually sends rather than a hand-made one.
crates/core/src/token.rs
+46
@@ -157,3 +157,49 @@ impl IntoTokenSource for Box<dyn TokenSource> {
157 self
158 }
159 }
160 +
161 +#[cfg(test)]
162 +mod tests {
163 + use super::*;
164 +
165 + #[test]
166 + fn a_static_token_mints_its_value_with_apples_twenty_minute_ceiling() {
167 + let minted = StaticToken::new("eyJ.static").mint().unwrap();
168 + assert_eq!(minted.value, "eyJ.static");
169 + assert_eq!(minted.lifetime, Duration::from_secs(20 * 60));
170 + }
171 +
172 + #[test]
173 + fn a_static_token_lifetime_can_be_overridden() {
174 + let minted = StaticToken::new("eyJ.static")
175 + .with_lifetime_secs(90)
176 + .mint()
177 + .unwrap();
178 + assert_eq!(minted.lifetime, Duration::from_secs(90));
179 + }
180 +
181 + /// Debug output ends up in logs and MCP transcripts, so the token
182 + /// value must never appear in it.
183 + #[test]
184 + fn debug_output_never_contains_the_token() {
185 + let token = StaticToken::new("eyJ.secret");
186 + let minted = token.mint().unwrap();
187 + let boxed: Box<dyn TokenSource> = Box::new(token.clone());
188 +
189 + for rendered in [
190 + format!("{token:?}"),
191 + format!("{minted:?}"),
192 + format!("{boxed:?}"),
193 + ] {
194 + assert!(!rendered.contains("eyJ.secret"), "leaked: {rendered}");
195 + }
196 + assert_eq!(boxed.describe(), "static token");
197 + }
198 +
199 + #[test]
200 + fn a_boxed_source_converts_into_a_token_source() {
201 + let boxed: Box<dyn TokenSource> = Box::new(StaticToken::new("eyJ.boxed"));
202 + let source = boxed.into_token_source();
203 + assert_eq!(source.mint().unwrap().value, "eyJ.boxed");
204 + }
205 +}