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
+}