Skip to main content

ares_types/models/
tenant.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "lowercase")]
5pub enum TenantTier {
6    Free,
7    Dev,
8    Pro,
9    Enterprise,
10}
11
12impl TenantTier {
13    #[allow(clippy::should_implement_trait)]
14    pub fn from_str(s: &str) -> Option<Self> {
15        match s.to_lowercase().as_str() {
16            "free" => Some(TenantTier::Free),
17            "dev" => Some(TenantTier::Dev),
18            "pro" => Some(TenantTier::Pro),
19            "enterprise" => Some(TenantTier::Enterprise),
20            _ => None,
21        }
22    }
23
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            TenantTier::Free => "free",
27            TenantTier::Dev => "dev",
28            TenantTier::Pro => "pro",
29            TenantTier::Enterprise => "enterprise",
30        }
31    }
32}
33
34impl std::str::FromStr for TenantTier {
35    type Err = String;
36    fn from_str(s: &str) -> Result<Self, Self::Err> {
37        TenantTier::from_str(s).ok_or_else(|| format!("unknown tenant tier: {}", s))
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TenantQuota {
43    pub tier: TenantTier,
44    pub requests_per_month: u64,
45    pub tokens_per_month: u64,
46    pub max_agents: u32,
47    pub requests_per_day: u64,
48}
49
50impl Default for TenantQuota {
51    fn default() -> Self {
52        Self::free()
53    }
54}
55
56impl TenantQuota {
57    pub fn free() -> Self {
58        Self {
59            tier: TenantTier::Free,
60            requests_per_month: 1_000,
61            tokens_per_month: 100_000,
62            max_agents: 1,
63            requests_per_day: 50,
64        }
65    }
66
67    pub fn dev() -> Self {
68        Self {
69            tier: TenantTier::Dev,
70            requests_per_month: 50_000,
71            tokens_per_month: 5_000_000,
72            max_agents: 10,
73            requests_per_day: 2_000,
74        }
75    }
76
77    pub fn pro() -> Self {
78        Self {
79            tier: TenantTier::Pro,
80            requests_per_month: 500_000,
81            tokens_per_month: 50_000_000,
82            max_agents: u32::MAX,
83            requests_per_day: 20_000,
84        }
85    }
86
87    pub fn enterprise() -> Self {
88        Self {
89            tier: TenantTier::Enterprise,
90            requests_per_month: u64::MAX,
91            tokens_per_month: u64::MAX,
92            max_agents: u32::MAX,
93            requests_per_day: u64::MAX,
94        }
95    }
96
97    pub fn from_tier(tier: &TenantTier) -> Self {
98        match tier {
99            TenantTier::Free => Self::free(),
100            TenantTier::Dev => Self::dev(),
101            TenantTier::Pro => Self::pro(),
102            TenantTier::Enterprise => Self::enterprise(),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Tenant {
109    pub id: String,
110    pub name: String,
111    pub tier: TenantTier,
112    pub created_at: i64,
113    pub updated_at: i64,
114}
115
116impl Tenant {
117    pub fn new(id: String, name: String, tier: TenantTier) -> Self {
118        let now = chrono::Utc::now().timestamp();
119        Self {
120            id,
121            name,
122            tier,
123            created_at: now,
124            updated_at: now,
125        }
126    }
127}
128
129pub const API_KEY_SCOPE_FULL: &str = "full";
130pub const API_KEY_SCOPE_INGEST: &str = "ingest";
131
132/// Maximum TTL for API keys in days (about 10 years).
133pub const API_KEY_MAX_TTL_DAYS: u32 = 3650;
134
135/// Normalizes a raw scopes value to the starter vocabulary (`full`/`ingest`).
136/// `None`, empty, or unknown values default to `full` so old rows and
137/// forward-compatible writers keep byte-identical full-key behavior.
138pub fn normalize_api_key_scope(raw: Option<&str>) -> String {
139    match raw.map(str::trim) {
140        Some(API_KEY_SCOPE_FULL) => API_KEY_SCOPE_FULL.to_string(),
141        Some(API_KEY_SCOPE_INGEST) => API_KEY_SCOPE_INGEST.to_string(),
142        _ => API_KEY_SCOPE_FULL.to_string(),
143    }
144}
145
146fn default_api_key_scope() -> String {
147    API_KEY_SCOPE_FULL.to_string()
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ApiKey {
152    pub id: String,
153    pub tenant_id: String,
154    pub key_hash: String,
155    pub key_prefix: String,
156    pub name: String,
157    pub is_active: bool,
158    pub created_at: i64,
159    pub expires_at: Option<i64>,
160    #[serde(default = "default_api_key_scope")]
161    pub scopes: String,
162    #[serde(default)]
163    pub last_used_at: Option<i64>,
164}
165
166impl ApiKey {
167    pub fn new(
168        id: String,
169        tenant_id: String,
170        key_hash: String,
171        key_prefix: String,
172        name: String,
173    ) -> Self {
174        Self {
175            id,
176            tenant_id,
177            key_hash,
178            key_prefix,
179            name,
180            is_active: true,
181            created_at: chrono::Utc::now().timestamp(),
182            expires_at: None,
183            scopes: API_KEY_SCOPE_FULL.to_string(),
184            last_used_at: None,
185        }
186    }
187
188    pub fn new_with_scope(
189        id: String,
190        tenant_id: String,
191        key_hash: String,
192        key_prefix: String,
193        name: String,
194        scopes: String,
195        expires_at: Option<i64>,
196    ) -> Self {
197        Self {
198            id,
199            tenant_id,
200            key_hash,
201            key_prefix,
202            name,
203            is_active: true,
204            created_at: chrono::Utc::now().timestamp(),
205            expires_at,
206            scopes: normalize_api_key_scope(Some(&scopes)),
207            last_used_at: None,
208        }
209    }
210
211    pub fn is_full(&self) -> bool {
212        self.scopes == API_KEY_SCOPE_FULL
213    }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum QuotaExceeded {
218    Monthly,
219    Daily,
220}
221
222impl QuotaExceeded {
223    pub fn message(self) -> &'static str {
224        match self {
225            Self::Monthly => "Monthly request quota exceeded",
226            Self::Daily => "Daily rate limit exceeded",
227        }
228    }
229}
230
231impl From<QuotaExceeded> for crate::types::AppError {
232    fn from(exceeded: QuotaExceeded) -> Self {
233        crate::types::AppError::RateLimited(exceeded.message().to_string())
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct TenantContext {
239    pub tenant_id: String,
240    pub tier: TenantTier,
241    pub quota: TenantQuota,
242    /// API key id that authenticated this request, if any.
243    /// `None` for internal contexts (tests, MCP without key threading).
244    #[serde(default)]
245    pub api_key_id: Option<String>,
246    /// Least-privilege scope for this key. Defaults to `full`.
247    #[serde(default = "default_api_key_scope")]
248    pub scopes: String,
249}
250
251impl TenantContext {
252    pub fn new(tenant_id: String, tier: TenantTier) -> Self {
253        let quota = TenantQuota::from_tier(&tier);
254        Self {
255            tenant_id,
256            tier,
257            quota,
258            api_key_id: None,
259            scopes: API_KEY_SCOPE_FULL.to_string(),
260        }
261    }
262
263    /// Context for a verified API key, carrying key identity plus scopes.
264    pub fn with_key(
265        tenant_id: String,
266        tier: TenantTier,
267        api_key_id: String,
268        scopes: String,
269    ) -> Self {
270        let quota = TenantQuota::from_tier(&tier);
271        Self {
272            tenant_id,
273            tier,
274            quota,
275            api_key_id: Some(api_key_id),
276            scopes: normalize_api_key_scope(Some(&scopes)),
277        }
278    }
279
280    /// `true` when this context carries full privileges (bypasses scope checks).
281    pub fn is_full_scope(&self) -> bool {
282        self.scopes == API_KEY_SCOPE_FULL
283    }
284
285    /// Least-privilege check: `full` allows every endpoint, `ingest` allows
286    /// only `POST */v1/usage/events`. Unknown scopes normalize to `full`
287    /// at verify time, so this only sees `full` or `ingest`.
288    pub fn allows_endpoint(&self, method: &str, path: &str) -> bool {
289        if self.is_full_scope() {
290            return true;
291        }
292        if self.scopes == API_KEY_SCOPE_INGEST {
293            // Paths arrive prefix-stripped inside nested routers
294            // (`/usage/events`, not `/api/v1/usage/events`); accept both.
295            return method.eq_ignore_ascii_case("POST")
296                && (path == "/usage/events" || path.ends_with("/v1/usage/events"));
297        }
298        // Defensive: unknown scopes fail closed here; verify normalizes to full.
299        false
300    }
301
302    pub fn admit(&self, monthly_requests: u64, daily_requests: u64) -> Result<(), QuotaExceeded> {
303        if monthly_requests >= self.quota.requests_per_month {
304            return Err(QuotaExceeded::Monthly);
305        }
306        if daily_requests >= self.quota.requests_per_day {
307            return Err(QuotaExceeded::Daily);
308        }
309        Ok(())
310    }
311
312    pub fn can_make_request(&self, monthly_requests: u64, daily_requests: u64) -> bool {
313        self.admit(monthly_requests, daily_requests).is_ok()
314    }
315
316    pub fn can_use_tokens(&self, monthly_tokens: u64, additional_tokens: u64) -> bool {
317        let Some(new_total) = monthly_tokens.checked_add(additional_tokens) else {
318            return false;
319        };
320        new_total <= self.quota.tokens_per_month
321    }
322}
323
324// Cordis Service impl — makes TenantContext a valid intercept key so per-request
325// tenant scope flows through the Cordis context via ctx.with_intercept(tenant_ctx).
326impl cordis::Service for TenantContext {
327    fn name(&self) -> &'static str {
328        "tenant_context"
329    }
330    fn init(&self, _ctx: &std::sync::Arc<cordis::Context>) -> cordis::ServiceInitFuture<'_> {
331        Box::pin(async { Ok(None) })
332    }
333    fn check(&self) -> bool {
334        true
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_tier_from_str() {
344        assert_eq!(TenantTier::from_str("free"), Some(TenantTier::Free));
345        assert_eq!(TenantTier::from_str("dev"), Some(TenantTier::Dev));
346        assert_eq!(TenantTier::from_str("pro"), Some(TenantTier::Pro));
347        assert_eq!(
348            TenantTier::from_str("enterprise"),
349            Some(TenantTier::Enterprise)
350        );
351        assert_eq!(TenantTier::from_str("unknown"), None);
352    }
353
354    #[test]
355    fn test_tier_as_str() {
356        assert_eq!(TenantTier::Free.as_str(), "free");
357        assert_eq!(TenantTier::Dev.as_str(), "dev");
358        assert_eq!(TenantTier::Pro.as_str(), "pro");
359        assert_eq!(TenantTier::Enterprise.as_str(), "enterprise");
360    }
361
362    #[test]
363    fn test_free_quota() {
364        let quota = TenantQuota::free();
365        assert_eq!(quota.tier, TenantTier::Free);
366        assert_eq!(quota.requests_per_month, 1_000);
367        assert_eq!(quota.tokens_per_month, 100_000);
368        assert_eq!(quota.max_agents, 1);
369        assert_eq!(quota.requests_per_day, 50);
370    }
371
372    #[test]
373    fn test_dev_quota() {
374        let quota = TenantQuota::dev();
375        assert_eq!(quota.tier, TenantTier::Dev);
376        assert_eq!(quota.requests_per_month, 50_000);
377        assert_eq!(quota.tokens_per_month, 5_000_000);
378        assert_eq!(quota.max_agents, 10);
379        assert_eq!(quota.requests_per_day, 2_000);
380    }
381
382    #[test]
383    fn test_pro_quota() {
384        let quota = TenantQuota::pro();
385        assert_eq!(quota.tier, TenantTier::Pro);
386        assert_eq!(quota.requests_per_month, 500_000);
387        assert_eq!(quota.tokens_per_month, 50_000_000);
388        assert_eq!(quota.max_agents, u32::MAX);
389        assert_eq!(quota.requests_per_day, 20_000);
390    }
391
392    #[test]
393    fn test_enterprise_quota() {
394        let quota = TenantQuota::enterprise();
395        assert_eq!(quota.tier, TenantTier::Enterprise);
396        assert_eq!(quota.requests_per_month, u64::MAX);
397        assert_eq!(quota.tokens_per_month, u64::MAX);
398    }
399
400    #[test]
401    fn test_quota_from_tier() {
402        assert_eq!(
403            TenantQuota::from_tier(&TenantTier::Free).requests_per_month,
404            1_000
405        );
406        assert_eq!(
407            TenantQuota::from_tier(&TenantTier::Dev).requests_per_month,
408            50_000
409        );
410        assert_eq!(
411            TenantQuota::from_tier(&TenantTier::Pro).requests_per_month,
412            500_000
413        );
414    }
415
416    #[test]
417    fn test_tenant_context_can_make_request() {
418        let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
419        assert!(ctx.can_make_request(0, 0));
420        assert!(ctx.can_make_request(999, 0));
421        assert!(ctx.can_make_request(0, 49));
422        assert!(!ctx.can_make_request(1000, 0));
423        assert!(!ctx.can_make_request(0, 50));
424    }
425
426    #[test]
427    fn test_tenant_context_admit_ok() {
428        let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
429        assert!(ctx.admit(0, 0).is_ok());
430        assert!(ctx.admit(999, 49).is_ok());
431    }
432
433    #[test]
434    fn test_tenant_context_admit_monthly() {
435        let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
436        assert_eq!(ctx.admit(1000, 0), Err(QuotaExceeded::Monthly));
437        assert_eq!(ctx.admit(1000, 50), Err(QuotaExceeded::Monthly));
438    }
439
440    #[test]
441    fn test_tenant_context_admit_daily() {
442        let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
443        assert_eq!(ctx.admit(0, 50), Err(QuotaExceeded::Daily));
444    }
445
446    #[test]
447    fn test_tenant_context_admit_enterprise() {
448        let ctx = TenantContext::new("ent".to_string(), TenantTier::Enterprise);
449        assert!(ctx.admit(1_000_000, 1_000_000).is_ok());
450        let err: crate::types::AppError = QuotaExceeded::Monthly.into();
451        match err {
452            crate::types::AppError::RateLimited(msg) => {
453                assert_eq!(msg, "Monthly request quota exceeded");
454            }
455            other => panic!("expected RateLimited, got {other:?}"),
456        }
457        let err: crate::types::AppError = QuotaExceeded::Daily.into();
458        match err {
459            crate::types::AppError::RateLimited(msg) => {
460                assert_eq!(msg, "Daily rate limit exceeded");
461            }
462            other => panic!("expected RateLimited, got {other:?}"),
463        }
464    }
465
466    #[test]
467    fn test_tenant_context_can_use_tokens() {
468        let ctx = TenantContext::new("test".to_string(), TenantTier::Free);
469        assert!(ctx.can_use_tokens(0, 100_000));
470        assert!(ctx.can_use_tokens(50_000, 50_000));
471        assert!(!ctx.can_use_tokens(50_000, 50_001));
472        assert!(!ctx.can_use_tokens(100_000, 1));
473    }
474
475    #[test]
476    fn test_tenant_creation() {
477        let tenant = Tenant::new("t1".to_string(), "Test Tenant".to_string(), TenantTier::Dev);
478        assert_eq!(tenant.id, "t1");
479        assert_eq!(tenant.name, "Test Tenant");
480        assert_eq!(tenant.tier, TenantTier::Dev);
481        assert!(tenant.created_at > 0);
482    }
483
484    #[test]
485    fn test_api_key_creation() {
486        let key = ApiKey::new(
487            "k1".to_string(),
488            "t1".to_string(),
489            "hash123".to_string(),
490            "ares_abc".to_string(),
491            "Test Key".to_string(),
492        );
493        assert_eq!(key.id, "k1");
494        assert_eq!(key.tenant_id, "t1");
495        assert!(key.is_active);
496        assert!(key.created_at > 0);
497    }
498
499    #[test]
500    fn test_tenant_tier_serde_roundtrip() {
501        for tier in [
502            TenantTier::Free,
503            TenantTier::Dev,
504            TenantTier::Pro,
505            TenantTier::Enterprise,
506        ] {
507            let json = serde_json::to_string(&tier).unwrap();
508            let parsed: TenantTier = serde_json::from_str(&json).unwrap();
509            assert_eq!(parsed, tier);
510            assert_eq!(parsed.as_str(), tier.as_str());
511        }
512    }
513
514    #[test]
515    fn test_tenant_tier_partial_eq_and_copy() {
516        let a = TenantTier::Pro;
517        let b = a;
518        assert_eq!(a, b);
519        assert_ne!(a, TenantTier::Free);
520        assert!(format!("{:?}", a).contains("Pro"));
521    }
522
523    #[test]
524    fn test_tenant_tier_from_str_case_insensitive() {
525        assert_eq!(TenantTier::from_str("FREE"), Some(TenantTier::Free));
526        assert_eq!(
527            TenantTier::from_str("Enterprise"),
528            Some(TenantTier::Enterprise)
529        );
530        assert_eq!(TenantTier::from_str(""), None);
531    }
532
533    #[test]
534    fn test_tenant_quota_default_is_free() {
535        let default = TenantQuota::default();
536        let free = TenantQuota::free();
537        assert_eq!(default.tier, free.tier);
538        assert_eq!(default.requests_per_month, free.requests_per_month);
539        assert_eq!(default.tokens_per_month, free.tokens_per_month);
540    }
541
542    #[test]
543    fn test_tenant_quota_serde_roundtrip() {
544        let quota = TenantQuota::enterprise();
545        let parsed: TenantQuota =
546            serde_json::from_str(&serde_json::to_string(&quota).unwrap()).unwrap();
547        assert_eq!(parsed.tier, TenantTier::Enterprise);
548        assert_eq!(parsed.requests_per_month, u64::MAX);
549        assert_eq!(parsed.max_agents, u32::MAX);
550    }
551
552    #[test]
553    fn test_tenant_serde_roundtrip_unicode_name() {
554        let tenant = Tenant::new("t-unicode".into(), "租户 🏢".into(), TenantTier::Pro);
555        let parsed: Tenant =
556            serde_json::from_str(&serde_json::to_string(&tenant).unwrap()).unwrap();
557        assert_eq!(parsed.name, "租户 🏢");
558        assert_eq!(parsed.tier, TenantTier::Pro);
559    }
560
561    #[test]
562    fn test_api_key_serde_roundtrip_with_expiry() {
563        let key = ApiKey {
564            id: "k2".into(),
565            tenant_id: "t1".into(),
566            key_hash: "hash".into(),
567            key_prefix: "ares_".into(),
568            name: String::new(),
569            is_active: false,
570            created_at: 0,
571            expires_at: Some(i64::MAX),
572            scopes: "full".into(),
573            last_used_at: None,
574        };
575        let parsed: ApiKey = serde_json::from_str(&serde_json::to_string(&key).unwrap()).unwrap();
576        assert!(!parsed.is_active);
577        assert_eq!(parsed.expires_at, Some(i64::MAX));
578        assert!(parsed.name.is_empty());
579    }
580
581    #[test]
582    fn normalize_scope_defaults_unknown_to_full() {
583        assert_eq!(normalize_api_key_scope(None), "full");
584        assert_eq!(normalize_api_key_scope(Some("")), "full");
585        assert_eq!(normalize_api_key_scope(Some("full")), "full");
586        assert_eq!(normalize_api_key_scope(Some("ingest")), "ingest");
587        assert_eq!(normalize_api_key_scope(Some("weird")), "full");
588        assert_eq!(normalize_api_key_scope(Some("  ingest  ")), "ingest");
589    }
590
591    #[test]
592    fn tenant_context_allows_endpoint_enforces_least_privilege() {
593        let full = TenantContext::new("t".into(), TenantTier::Free);
594        assert!(full.is_full_scope());
595        assert!(full.allows_endpoint("POST", "/v1/agents/x/run"));
596        assert!(full.allows_endpoint("POST", "/v1/usage/events"));
597        let ingest =
598            TenantContext::with_key("t".into(), TenantTier::Free, "k".into(), "ingest".into());
599        assert!(!ingest.is_full_scope());
600        assert!(!ingest.allows_endpoint("POST", "/v1/agents/x/run"));
601        assert!(!ingest.allows_endpoint("GET", "/v1/usage/events"));
602        assert!(ingest.allows_endpoint("POST", "/v1/usage/events"));
603        assert!(ingest.allows_endpoint("POST", "/api/v1/usage/events"));
604        assert!(ingest.allows_endpoint("POST", "/usage/events"));
605        assert!(!ingest.allows_endpoint("GET", "/usage/events"));
606    }
607
608    #[test]
609    fn test_tenant_context_serde_roundtrip() {
610        let ctx = TenantContext::new("tenant-1".into(), TenantTier::Dev);
611        let parsed: TenantContext =
612            serde_json::from_str(&serde_json::to_string(&ctx).unwrap()).unwrap();
613        assert_eq!(parsed.tenant_id, "tenant-1");
614        assert_eq!(parsed.tier, TenantTier::Dev);
615        assert_eq!(parsed.quota.requests_per_day, 2_000);
616    }
617
618    #[test]
619    fn test_tenant_context_token_overflow_boundary() {
620        let ctx = TenantContext::new("t".into(), TenantTier::Free);
621        assert!(!ctx.can_use_tokens(u64::MAX, 1));
622        assert!(ctx.can_use_tokens(0, 0));
623        assert!(!ctx.can_make_request(u64::MAX, 0));
624    }
625
626    #[test]
627    fn test_enterprise_context_unlimited_requests() {
628        let ctx = TenantContext::new("ent".into(), TenantTier::Enterprise);
629        assert!(ctx.can_make_request(u64::MAX - 1, u64::MAX - 1));
630        assert!(ctx.can_use_tokens(u64::MAX - 1, 1));
631    }
632
633    #[test]
634    fn test_enterprise_quota_full_limits() {
635        let quota = TenantQuota::enterprise();
636        assert_eq!(quota.max_agents, u32::MAX);
637        assert_eq!(quota.requests_per_day, u64::MAX);
638    }
639
640    #[test]
641    fn test_quota_from_tier_enterprise() {
642        let quota = TenantQuota::from_tier(&TenantTier::Enterprise);
643        assert_eq!(quota.tier, TenantTier::Enterprise);
644        assert_eq!(quota.requests_per_month, u64::MAX);
645        assert_eq!(quota.tokens_per_month, u64::MAX);
646        assert_eq!(quota.max_agents, u32::MAX);
647        assert_eq!(quota.requests_per_day, u64::MAX);
648    }
649
650    #[test]
651    fn test_tenant_tier_serde_json_is_lowercase() {
652        let json = serde_json::to_string(&TenantTier::Pro).unwrap();
653        assert_eq!(json, "\"pro\"");
654        assert_eq!(
655            serde_json::from_str::<TenantTier>("\"pro\"").unwrap(),
656            TenantTier::Pro
657        );
658    }
659
660    #[test]
661    fn test_tenant_tier_serde_rejects_unknown_variant() {
662        let err = serde_json::from_str::<TenantTier>("\"platinum\"").unwrap_err();
663        assert!(err.is_data());
664    }
665
666    #[test]
667    fn test_tier_from_str_rejects_whitespace_and_garbage() {
668        assert_eq!(TenantTier::from_str(" free"), None);
669        assert_eq!(TenantTier::from_str("free "), None);
670        assert_eq!(TenantTier::from_str("free\n"), None);
671        assert_eq!(TenantTier::from_str("pro "), None);
672    }
673
674    #[test]
675    fn test_api_key_serde_roundtrip_from_new() {
676        let key = ApiKey::new(
677            "k-new".into(),
678            "tenant".into(),
679            "hash".into(),
680            "ares_xyz".into(),
681            "Primary".into(),
682        );
683        let parsed: ApiKey = serde_json::from_str(&serde_json::to_string(&key).unwrap()).unwrap();
684        assert_eq!(parsed.id, key.id);
685        assert_eq!(parsed.tenant_id, key.tenant_id);
686        assert_eq!(parsed.key_hash, key.key_hash);
687        assert_eq!(parsed.key_prefix, key.key_prefix);
688        assert_eq!(parsed.name, key.name);
689        assert!(parsed.is_active);
690        assert_eq!(parsed.created_at, key.created_at);
691        assert_eq!(parsed.expires_at, None);
692    }
693
694    #[test]
695    fn test_tenant_serde_preserves_timestamps_and_id() {
696        let tenant = Tenant {
697            id: "fixed-id".into(),
698            name: "Acme".into(),
699            tier: TenantTier::Dev,
700            created_at: 1_700_000_000,
701            updated_at: 1_700_000_001,
702        };
703        let parsed: Tenant =
704            serde_json::from_str(&serde_json::to_string(&tenant).unwrap()).unwrap();
705        assert_eq!(parsed.id, "fixed-id");
706        assert_eq!(parsed.created_at, 1_700_000_000);
707        assert_eq!(parsed.updated_at, 1_700_000_001);
708    }
709
710    #[test]
711    fn test_tenant_quota_serde_rejects_missing_field() {
712        let err = serde_json::from_str::<TenantQuota>(r#"{"tier":"free"}"#).unwrap_err();
713        assert!(err.is_data());
714    }
715
716    #[test]
717    fn test_tenant_quota_serde_all_tiers_roundtrip() {
718        for factory in [
719            TenantQuota::free,
720            TenantQuota::dev,
721            TenantQuota::pro,
722            TenantQuota::enterprise,
723        ] {
724            let quota = factory();
725            let parsed: TenantQuota =
726                serde_json::from_str(&serde_json::to_string(&quota).unwrap()).unwrap();
727            assert_eq!(parsed.tier, quota.tier);
728            assert_eq!(parsed.requests_per_month, quota.requests_per_month);
729            assert_eq!(parsed.tokens_per_month, quota.tokens_per_month);
730            assert_eq!(parsed.max_agents, quota.max_agents);
731            assert_eq!(parsed.requests_per_day, quota.requests_per_day);
732        }
733    }
734
735    #[test]
736    fn test_can_make_request_exact_monthly_and_daily_boundaries() {
737        let ctx = TenantContext::new("t".into(), TenantTier::Free);
738        assert!(ctx.can_make_request(999, 49));
739        assert!(!ctx.can_make_request(1_000, 0));
740        assert!(!ctx.can_make_request(0, 50));
741        assert!(!ctx.can_make_request(1_000, 50));
742    }
743
744    #[test]
745    fn test_can_use_tokens_exact_monthly_ceiling() {
746        let ctx = TenantContext::new("t".into(), TenantTier::Free);
747        assert!(ctx.can_use_tokens(99_999, 1));
748        assert!(ctx.can_use_tokens(100_000, 0));
749        assert!(!ctx.can_use_tokens(100_000, 1));
750        assert!(!ctx.can_use_tokens(99_999, 2));
751    }
752
753    #[test]
754    fn test_dev_context_daily_request_boundary() {
755        let ctx = TenantContext::new("dev-tenant".into(), TenantTier::Dev);
756        assert!(ctx.can_make_request(0, 1_999));
757        assert!(!ctx.can_make_request(0, 2_000));
758    }
759
760    #[test]
761    fn test_tenant_context_clone_matches_original() {
762        let ctx = TenantContext::new("clone-me".into(), TenantTier::Pro);
763        let cloned = ctx.clone();
764        assert_eq!(cloned.tenant_id, ctx.tenant_id);
765        assert_eq!(cloned.tier, ctx.tier);
766        assert_eq!(
767            cloned.quota.requests_per_month,
768            ctx.quota.requests_per_month
769        );
770    }
771
772    #[test]
773    fn test_enterprise_context_rejects_at_hard_ceiling() {
774        let ctx = TenantContext::new("ent".into(), TenantTier::Enterprise);
775        assert!(!ctx.can_make_request(u64::MAX, 0));
776        assert!(!ctx.can_make_request(0, u64::MAX));
777        assert!(!ctx.can_use_tokens(u64::MAX, 1));
778    }
779
780    #[test]
781    fn tenant_context_readable_via_cordis_intercept() {
782        // Cordis design: per-request tenant scope should flow via
783        // ctx.with_intercept(TenantContext) so downstream services read it
784        // from the context (ctx.get::<TenantContext>()) instead of Axum
785        // request extensions.
786        use std::sync::Arc;
787        let root: Arc<cordis::Context> = cordis::Context::new_root();
788
789        // Before intercept — no TenantContext in context.
790        assert!(root.get::<TenantContext>().is_none());
791
792        // After intercept — TenantContext is readable.
793        let tc = TenantContext::new("acme".into(), TenantTier::Pro);
794        let child = root.with_intercept(tc);
795        let retrieved = child
796            .get::<TenantContext>()
797            .expect("intercept must make TenantContext readable");
798        assert_eq!(retrieved.tenant_id, "acme");
799        assert_eq!(retrieved.tier, TenantTier::Pro);
800    }
801}