1use std::fmt;
2use std::sync::Arc;
3
4use uselesskey_core::Factory;
5
6use crate::TokenSpec;
7use crate::srp::shape::{NegativeToken, generate_negative_token, generate_token};
8
9pub const DOMAIN_TOKEN_FIXTURE: &str = "uselesskey:token:fixture";
13
14#[derive(Clone)]
29pub struct TokenFixture {
30 factory: Factory,
31 label: String,
32 spec: TokenSpec,
33 inner: Arc<Inner>,
34}
35
36struct Inner {
37 value: String,
38}
39
40impl fmt::Debug for TokenFixture {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.debug_struct("TokenFixture")
43 .field("label", &self.label)
44 .field("spec", &self.spec)
45 .finish_non_exhaustive()
46 }
47}
48
49pub trait TokenFactoryExt {
51 fn token(&self, label: impl AsRef<str>, spec: TokenSpec) -> TokenFixture;
66
67 fn token_with_variant(
82 &self,
83 label: impl AsRef<str>,
84 spec: TokenSpec,
85 variant: impl AsRef<str>,
86 ) -> TokenFixture;
87}
88
89impl TokenFactoryExt for Factory {
90 fn token(&self, label: impl AsRef<str>, spec: TokenSpec) -> TokenFixture {
91 TokenFixture::new(self.clone(), label.as_ref(), spec)
92 }
93
94 fn token_with_variant(
95 &self,
96 label: impl AsRef<str>,
97 spec: TokenSpec,
98 variant: impl AsRef<str>,
99 ) -> TokenFixture {
100 let label = label.as_ref();
101 let variant = variant.as_ref();
102 let factory = self.clone();
103 let inner = load_inner(&factory, label, spec, variant);
104 TokenFixture {
105 factory,
106 label: label.to_string(),
107 spec,
108 inner,
109 }
110 }
111}
112
113impl TokenFixture {
114 fn new(factory: Factory, label: &str, spec: TokenSpec) -> Self {
115 let inner = load_inner(&factory, label, spec, "good");
116 Self {
117 factory,
118 label: label.to_string(),
119 spec,
120 inner,
121 }
122 }
123
124 #[allow(
125 dead_code,
126 reason = "reserved for future variant-based negative fixtures"
127 )]
128 fn load_variant(&self, variant: &str) -> Arc<Inner> {
129 load_inner(&self.factory, &self.label, self.spec, variant)
130 }
131
132 pub fn spec(&self) -> TokenSpec {
144 self.spec
145 }
146
147 pub fn label(&self) -> &str {
159 &self.label
160 }
161
162 pub fn value(&self) -> &str {
175 &self.inner.value
176 }
177
178 pub fn authorization_header(&self) -> String {
197 let scheme = self.spec.authorization_scheme();
198 format!("{scheme} {}", self.value())
199 }
200
201 pub fn negative_value(&self, variant: NegativeToken) -> String {
217 load_negative_inner(&self.factory, &self.label, self.spec, variant)
218 .value
219 .clone()
220 }
221}
222
223fn load_inner(factory: &Factory, label: &str, spec: TokenSpec, variant: &str) -> Arc<Inner> {
224 let spec_bytes = spec.stable_bytes();
225
226 factory.get_or_init(DOMAIN_TOKEN_FIXTURE, label, &spec_bytes, variant, |seed| {
227 let value = generate_token(label, spec, seed);
228 Inner { value }
229 })
230}
231
232fn load_negative_inner(
233 factory: &Factory,
234 label: &str,
235 spec: TokenSpec,
236 variant: NegativeToken,
237) -> Arc<Inner> {
238 let spec_bytes = spec.stable_bytes();
239 let cache_variant = format!("negative:{}", variant.variant_name());
240
241 factory.get_or_init(
242 DOMAIN_TOKEN_FIXTURE,
243 label,
244 &spec_bytes,
245 &cache_variant,
246 |seed| {
247 let value = generate_negative_token(label, spec, seed, variant);
248 Inner { value }
249 },
250 )
251}
252
253#[cfg(test)]
254mod tests {
255 use base64::Engine as _;
256 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
257
258 use super::*;
259 use uselesskey_core::Seed;
260
261 #[test]
262 fn deterministic_token_is_stable() {
263 let fx = Factory::deterministic(Seed::from_env_value("token-det").unwrap());
264 let t1 = fx.token("svc", TokenSpec::api_key());
265 let t2 = fx.token("svc", TokenSpec::api_key());
266 assert_eq!(t1.value(), t2.value());
267 }
268
269 #[test]
270 fn random_mode_still_caches_per_identity() {
271 let fx = Factory::random();
272 let t1 = fx.token("svc", TokenSpec::bearer());
273 let t2 = fx.token("svc", TokenSpec::bearer());
274 assert_eq!(t1.value(), t2.value());
275 }
276
277 #[test]
278 fn different_labels_produce_different_tokens() {
279 let fx = Factory::deterministic(Seed::from_env_value("token-label").unwrap());
280 let a = fx.token("a", TokenSpec::bearer());
281 let b = fx.token("b", TokenSpec::bearer());
282 assert_ne!(a.value(), b.value());
283 }
284
285 #[test]
286 fn api_key_shape_is_realistic() {
287 let fx = Factory::random();
288 let token = fx.token("svc", TokenSpec::api_key());
289
290 assert!(token.value().starts_with("uk_test_"));
291 let suffix = &token.value()["uk_test_".len()..];
292 assert_eq!(suffix.len(), 32);
293 assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
294 }
295
296 #[test]
297 fn bearer_header_uses_bearer_scheme() {
298 let fx = Factory::random();
299 let token = fx.token("svc", TokenSpec::bearer());
300 let header = token.authorization_header();
301 assert!(header.starts_with("Bearer "));
302 assert!(header.ends_with(token.value()));
303 }
304
305 #[test]
306 fn oauth_token_has_three_segments_and_json_header() {
307 let fx = Factory::deterministic(Seed::from_env_value("token-oauth").unwrap());
308 let token = fx.token("issuer", TokenSpec::oauth_access_token());
309
310 let parts: Vec<&str> = token.value().split('.').collect();
311 assert_eq!(parts.len(), 3);
312
313 let header_bytes = URL_SAFE_NO_PAD
314 .decode(parts[0])
315 .expect("decode JWT header segment");
316 let payload_bytes = URL_SAFE_NO_PAD
317 .decode(parts[1])
318 .expect("decode JWT payload segment");
319
320 let header: serde_json::Value = serde_json::from_slice(&header_bytes).expect("header json");
321 let payload: serde_json::Value =
322 serde_json::from_slice(&payload_bytes).expect("payload json");
323
324 assert_eq!(header["alg"], "RS256");
325 assert_eq!(header["typ"], "JWT");
326 assert_eq!(payload["sub"], "issuer");
327 assert_eq!(payload["iss"], "uselesskey");
328 }
329
330 #[test]
331 fn different_variants_produce_different_tokens() {
332 let fx = Factory::deterministic(Seed::from_env_value("token-variant").unwrap());
333 let token = fx.token("svc", TokenSpec::bearer());
334 let other = token.load_variant("other");
335
336 assert_ne!(token.value(), other.value.as_str());
337 }
338
339 #[test]
340 fn token_with_variant_uses_custom_variant() {
341 let fx = Factory::deterministic(Seed::from_env_value("token-variant2").unwrap());
342 let good = fx.token("svc", TokenSpec::api_key());
343 let custom = fx.token_with_variant("svc", TokenSpec::api_key(), "custom");
344
345 assert_ne!(good.value(), custom.value());
346 }
347
348 #[test]
349 fn negative_value_is_cached_and_stable() {
350 let fx = Factory::deterministic(Seed::from_env_value("token-negative").unwrap());
351 let token = fx.token("issuer", TokenSpec::oauth_access_token());
352
353 let a = token.negative_value(NegativeToken::ExpiredClaims);
354 let b = token.negative_value(NegativeToken::ExpiredClaims);
355
356 assert_eq!(a, b);
357 assert_ne!(a, token.value());
358 assert_eq!(a.matches('.').count(), 2);
359 }
360
361 #[test]
362 fn negative_api_key_near_miss_keeps_positive_fixture_unchanged() {
363 let fx = Factory::deterministic(Seed::from_env_value("token-negative-api").unwrap());
364 let token = fx.token("billing", TokenSpec::api_key());
365
366 let near_miss = token.negative_value(NegativeToken::NearMissApiKey);
367
368 assert!(token.value().starts_with("uk_test_"));
369 assert!(near_miss.starts_with("uk_tset_"));
370 assert!(!near_miss.starts_with("uk_test_"));
371 assert_ne!(near_miss, token.value());
372 }
373
374 #[test]
375 fn debug_does_not_include_token_value() {
376 let fx = Factory::random();
377 let token = fx.token("debug-label", TokenSpec::api_key());
378 let dbg = format!("{token:?}");
379 assert!(dbg.contains("TokenFixture"));
380 assert!(dbg.contains("debug-label"));
381 assert!(!dbg.contains(token.value()));
382 }
383
384 #[test]
385 fn random_base62_uses_full_alphabet() {
386 let fx = Factory::deterministic(Seed::from_env_value("base62-test").unwrap());
387 let t = fx.token("alphabet-test", TokenSpec::api_key());
388 let value = t.value();
389 let suffix = value.strip_prefix("uk_test_").expect("API key prefix");
392 assert!(
396 suffix
397 .chars()
398 .any(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
399 "random suffix should use full base62 alphabet, got: {suffix}"
400 );
401 }
402}