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
132pub const API_KEY_MAX_TTL_DAYS: u32 = 3650;
134
135pub 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 #[serde(default)]
245 pub api_key_id: Option<String>,
246 #[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 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 pub fn is_full_scope(&self) -> bool {
282 self.scopes == API_KEY_SCOPE_FULL
283 }
284
285 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 return method.eq_ignore_ascii_case("POST")
296 && (path == "/usage/events" || path.ends_with("/v1/usage/events"));
297 }
298 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
324impl 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("a).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("a).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 use std::sync::Arc;
787 let root: Arc<cordis::Context> = cordis::Context::new_root();
788
789 assert!(root.get::<TenantContext>().is_none());
791
792 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}