1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use hmac::{Hmac, Mac};
3use reqwest::Client as HttpClient;
4use serde_json::json;
5use sha2::Sha256;
6use std::collections::HashMap;
7use std::sync::OnceLock;
8use std::time::{SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11use crate::error::VortexError;
12use crate::types::*;
13
14type HmacSha256 = Hmac<Sha256>;
15
16pub struct VortexClient {
21 api_key: String,
22 base_url: String,
23 http_client: HttpClient,
24}
25
26impl VortexClient {
27
28 fn transform_scope(mut scope: InvitationScope) -> InvitationScope {
30 scope.scope_id = scope.scope.clone();
31 scope
32 }
33
34 fn transform_invitation(mut inv: Invitation) -> Invitation {
36 let transformed: Vec<InvitationScope> = inv.groups.into_iter().map(Self::transform_scope).collect();
37 inv.scopes = transformed.clone();
38 inv.groups = transformed;
39 inv
40 }
41
42 fn transform_invitations(invitations: Vec<Invitation>) -> Vec<Invitation> {
44 invitations.into_iter().map(Self::transform_invitation).collect()
45 }
46
47 fn transform_create_request(mut request: CreateInvitationRequest) -> CreateInvitationRequest {
49 if request.scope_id.is_some() && request.groups.is_none() && request.scopes.is_none() {
51 let scope_id = request.scope_id.take().unwrap();
52 let scope_type = request.scope_type_flat.take().unwrap_or_default();
53 let scope_name = request.scope_name.take().unwrap_or_default();
54 request.groups = Some(vec![CreateInvitationScope::new(&scope_type, &scope_id, &scope_name)]);
55 }
56 else if request.scopes.is_some() && request.groups.is_none() {
58 request.groups = request.scopes.take();
59 }
60 request.scope_id = None;
61 request.scope_type_flat = None;
62 request.scope_name = None;
63 request.scopes = None;
64 request
65 }
66
67 pub fn new(api_key: String) -> Self {
82 let base_url = std::env::var("VORTEX_API_BASE_URL")
83 .unwrap_or_else(|_| "https://api.vortexsoftware.com".to_string());
84
85 Self {
86 api_key,
87 base_url,
88 http_client: HttpClient::new(),
89 }
90 }
91
92 pub fn with_base_url(api_key: String, base_url: String) -> Self {
99 Self {
100 api_key,
101 base_url,
102 http_client: HttpClient::new(),
103 }
104 }
105
106 pub fn sign(&self, user: &User) -> Result<String, VortexError> {
151 let (kid, signing_key) = self.parse_and_derive_key()?;
152
153 let mut canonical = serde_json::Map::new();
155 canonical.insert("userId".to_string(), json!(user.id));
156 canonical.insert("userEmail".to_string(), json!(&user.email));
157 let user_name = user.name.as_ref().or(user.user_name.as_ref());
159 if let Some(name) = user_name {
160 canonical.insert("name".to_string(), json!(name));
161 }
162 let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
163 if let Some(avatar) = user_avatar_url {
164 canonical.insert("avatarUrl".to_string(), json!(avatar));
165 }
166 if let Some(ref scopes) = user.admin_scopes {
167 if !scopes.is_empty() {
168 canonical.insert("adminScopes".to_string(), json!(scopes));
169 }
170 }
171 if let Some(ref domains) = user.allowed_email_domains {
172 if !domains.is_empty() {
173 canonical.insert("allowedEmailDomains".to_string(), json!(domains));
174 }
175 }
176 if let Some(ref extra) = user.extra {
178 for (k, v) in extra {
179 if !canonical.contains_key(k) {
180 canonical.insert(k.clone(), v.clone());
181 }
182 }
183 }
184
185 let canonical_json = serde_json::to_string(&serde_json::Value::Object(canonical))
187 .map_err(|e| VortexError::CryptoError(format!("Failed to serialize canonical payload: {}", e)))?;
188
189 let mut mac = HmacSha256::new_from_slice(&signing_key)
190 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
191 mac.update(canonical_json.as_bytes());
192 let digest = hex::encode(mac.finalize().into_bytes());
193
194 Ok(format!("{}:{}", kid, digest))
195 }
196
197 fn parse_and_derive_key(&self) -> Result<(String, Vec<u8>), VortexError> {
198 let parts: Vec<&str> = self.api_key.split('.').collect();
199 if parts.len() != 3 || parts[0] != "VRTX" {
200 return Err(VortexError::InvalidApiKey("Invalid API key format".to_string()));
201 }
202
203 let uuid_bytes = URL_SAFE_NO_PAD
204 .decode(parts[1])
205 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode key ID: {}", e)))?;
206 let kid = Uuid::from_slice(&uuid_bytes)
207 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to parse UUID: {}", e)))?
208 .to_string();
209
210 let mut mac = HmacSha256::new_from_slice(parts[2].as_bytes())
211 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
212 mac.update(kid.as_bytes());
213 let signing_key = mac.finalize().into_bytes().to_vec();
214
215 Ok((kid, signing_key))
216 }
217
218 fn parse_expires_in(&self, expires_in: &ExpiresIn) -> Result<u64, VortexError> {
220 match expires_in {
221 ExpiresIn::Seconds(s) => {
222 if *s == 0 {
223 return Err(VortexError::InvalidRequest(
224 "expires_in must be a positive number of seconds".to_string(),
225 ));
226 }
227 Ok(*s)
228 }
229 ExpiresIn::Duration(s) => {
230 static DURATION_RE: OnceLock<regex::Regex> = OnceLock::new();
232 let re = DURATION_RE.get_or_init(|| {
233 regex::Regex::new(r"^(\d+)(m|h|d)$").expect("Invalid regex pattern")
234 });
235
236 if let Some(caps) = re.captures(s) {
237 let value: u64 = caps[1].parse().map_err(|_| {
238 VortexError::InvalidRequest(format!(
239 "Invalid expires_in format: \"{}\". Value is too large.",
240 s
241 ))
242 })?;
243
244 if value == 0 {
246 return Err(VortexError::InvalidRequest(
247 "expires_in must be a positive duration".to_string(),
248 ));
249 }
250
251 let unit = &caps[2];
252 let seconds = match unit {
253 "m" => value.checked_mul(60),
254 "h" => value.checked_mul(3600),
255 "d" => value.checked_mul(86400),
256 _ => {
257 return Err(VortexError::InvalidRequest(format!(
258 "Unknown time unit: {}",
259 unit
260 )))
261 }
262 }
263 .ok_or_else(|| {
264 VortexError::InvalidRequest(format!(
265 "expires_in value is too large: \"{}\"",
266 s
267 ))
268 })?;
269
270 Ok(seconds)
271 } else {
272 Err(VortexError::InvalidRequest(format!(
273 "Invalid expires_in format: \"{}\". Use \"5m\", \"1h\", \"24h\", \"7d\" or seconds.",
274 s
275 )))
276 }
277 }
278 }
279 }
280
281 pub fn generate_token(
300 &self,
301 payload: &GenerateTokenPayload,
302 options: Option<&GenerateTokenOptions>,
303 ) -> Result<String, VortexError> {
304 if payload.user.is_none() || payload.user.as_ref().map(|u| u.id.is_empty()).unwrap_or(true) {
306 eprintln!("[Vortex SDK] Warning: signing payload without user.id means invitations won't be securely attributed.");
307 }
308
309 let (kid, signing_key) = self.parse_and_derive_key()?;
310
311 let expires_in_seconds = if let Some(opts) = options {
313 if let Some(ref exp) = opts.expires_in {
314 self.parse_expires_in(exp)?
315 } else {
316 300
317 }
318 } else {
319 300
320 };
321
322 let now = SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .unwrap()
325 .as_secs();
326 let exp = now.checked_add(expires_in_seconds).ok_or_else(|| {
327 VortexError::InvalidRequest(
328 "expires_in is too large and would overflow the expiration timestamp".to_string(),
329 )
330 })?;
331
332 let header = json!({
334 "alg": "HS256",
335 "typ": "JWT",
336 "kid": kid
337 });
338
339 let mut jwt_payload = serde_json::to_value(payload)
341 .map_err(|e| VortexError::CryptoError(format!("Failed to serialize payload: {}", e)))?;
342 if let Some(obj) = jwt_payload.as_object_mut() {
343 obj.insert("iat".to_string(), json!(now));
344 obj.insert("exp".to_string(), json!(exp));
345 }
346
347 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
349 let payload_b64 = URL_SAFE_NO_PAD.encode(jwt_payload.to_string().as_bytes());
350
351 let to_sign = format!("{}.{}", header_b64, payload_b64);
353 let mut mac = HmacSha256::new_from_slice(&signing_key)
354 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
355 mac.update(to_sign.as_bytes());
356 let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
357
358 Ok(format!("{}.{}", to_sign, signature))
359 }
360
361 pub fn generate_jwt(
362 &self,
363 user: &User,
364 extra: Option<HashMap<String, serde_json::Value>>,
365 ) -> Result<String, VortexError> {
366 let parts: Vec<&str> = self.api_key.split('.').collect();
368 if parts.len() != 3 {
369 return Err(VortexError::InvalidApiKey(
370 "Invalid API key format".to_string(),
371 ));
372 }
373
374 let prefix = parts[0];
375 let encoded_id = parts[1];
376 let key = parts[2];
377
378 if prefix != "VRTX" {
379 return Err(VortexError::InvalidApiKey(
380 "Invalid API key prefix".to_string(),
381 ));
382 }
383
384 let id_bytes = URL_SAFE_NO_PAD
386 .decode(encoded_id)
387 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
388
389 if id_bytes.len() != 16 {
390 return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
391 }
392
393 let uuid = Uuid::from_slice(&id_bytes)
394 .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
395 let uuid_str = uuid.to_string();
396
397 let now = SystemTime::now()
398 .duration_since(UNIX_EPOCH)
399 .unwrap()
400 .as_secs();
401 let expires = now + 3600; let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
405 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
406 hmac.update(uuid_str.as_bytes());
407 let signing_key = hmac.finalize().into_bytes();
408
409 let header = json!({
411 "iat": now,
412 "alg": "HS256",
413 "typ": "JWT",
414 "kid": uuid_str,
415 });
416
417 let mut payload_json = json!({
419 "userId": user.id,
420 "userEmail": user.email,
421 "expires": expires,
422 });
423
424 let user_name = user.name.as_ref().or(user.user_name.as_ref());
426 if let Some(name) = user_name {
427 payload_json["name"] = json!(name);
428 }
429
430 let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
432 if let Some(avatar_url) = user_avatar_url {
433 payload_json["avatarUrl"] = json!(avatar_url);
434 }
435
436 if let Some(ref scopes) = user.admin_scopes {
438 payload_json["adminScopes"] = json!(scopes);
439 }
440
441 if let Some(ref domains) = user.allowed_email_domains {
443 if !domains.is_empty() {
444 payload_json["allowedEmailDomains"] = json!(domains);
445 }
446 }
447
448 if let Some(extra_props) = extra {
450 for (key, value) in extra_props {
451 payload_json[key] = value;
452 }
453 }
454
455 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
457 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
458
459 let to_sign = format!("{}.{}", header_b64, payload_b64);
461 let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
462 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
463 sig_hmac.update(to_sign.as_bytes());
464 let signature = sig_hmac.finalize().into_bytes();
465 let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
466
467 Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
468 }
469
470 pub async fn get_invitations_by_target(
472 &self,
473 target_type: &str,
474 target_value: &str,
475 ) -> Result<Vec<Invitation>, VortexError> {
476 let mut params = HashMap::new();
477 params.insert("targetType", target_type);
478 params.insert("targetValue", target_value);
479
480 let response: InvitationsResponse = self
481 .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
482 .await?;
483
484 Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
485 }
486
487 pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
489 let result: Invitation = self.api_request(
490 "GET",
491 &format!("/api/v1/invitations/{}", invitation_id),
492 None::<&()>,
493 None,
494 )
495 .await?;
496 Ok(Self::transform_invitation(result))
497 }
498
499 pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
501 self.api_request::<(), ()>(
502 "DELETE",
503 &format!("/api/v1/invitations/{}", invitation_id),
504 None,
505 None,
506 )
507 .await?;
508 Ok(())
509 }
510
511 pub async fn accept_invitations(
542 &self,
543 invitation_ids: Vec<String>,
544 param: impl Into<crate::types::AcceptInvitationParam>,
545 ) -> Result<Invitation, VortexError> {
546 use crate::types::{AcceptInvitationParam, AcceptUser};
547
548 let param = param.into();
549
550 let user = match param {
552 AcceptInvitationParam::Targets(targets) => {
553 eprintln!("[Vortex SDK] DEPRECATED: Passing a vector of targets is deprecated. Use the AcceptUser format and call once per user instead.");
554
555 if targets.is_empty() {
556 return Err(VortexError::InvalidRequest("No targets provided".to_string()));
557 }
558
559 let mut last_result = None;
560 let mut last_error = None;
561
562 for target in targets {
563 let user = match target.target_type {
565 InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
566 InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
567 _ => AcceptUser::new().with_email(&target.value),
568 };
569
570 match Box::pin(self.accept_invitations(invitation_ids.clone(), user)).await {
571 Ok(result) => last_result = Some(result),
572 Err(e) => last_error = Some(e),
573 }
574 }
575
576 if let Some(err) = last_error {
577 return Err(err);
578 }
579
580 return last_result.ok_or_else(|| VortexError::InvalidRequest("No results".to_string()));
581 }
582 AcceptInvitationParam::Target(target) => {
583 eprintln!("[Vortex SDK] DEPRECATED: Passing an InvitationTarget is deprecated. Use the AcceptUser format instead: AcceptUser::new().with_email(\"user@example.com\")");
584
585 match target.target_type {
587 InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
588 InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
589 _ => AcceptUser::new().with_email(&target.value), }
591 }
592 AcceptInvitationParam::User(user) => user,
593 };
594
595 if user.email.is_none() && user.phone.is_none() {
597 return Err(VortexError::InvalidRequest(
598 "User must have either email or phone".to_string(),
599 ));
600 }
601
602 let body = json!({
603 "invitationIds": invitation_ids,
604 "user": user,
605 });
606
607 let result: Invitation = self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
608 .await?;
609 Ok(Self::transform_invitation(result))
610 }
611
612 pub async fn accept_invitation(
637 &self,
638 invitation_id: &str,
639 user: crate::types::AcceptUser,
640 ) -> Result<Invitation, VortexError> {
641 self.accept_invitations(vec![invitation_id.to_string()], user).await
642 }
643
644 pub async fn delete_invitations_by_scope(
646 &self,
647 scope_type: &str,
648 scope: &str,
649 ) -> Result<(), VortexError> {
650 self.api_request::<(), ()>(
651 "DELETE",
652 &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
653 None,
654 None,
655 )
656 .await?;
657 Ok(())
658 }
659
660 pub async fn get_invitations_by_scope(
662 &self,
663 scope_type: &str,
664 scope: &str,
665 ) -> Result<Vec<Invitation>, VortexError> {
666 let response: InvitationsResponse = self
667 .api_request(
668 "GET",
669 &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
670 None::<&()>,
671 None,
672 )
673 .await?;
674
675 Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
676 }
677
678 pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
680 let result: Invitation = self.api_request(
681 "POST",
682 &format!("/api/v1/invitations/{}/reinvite", invitation_id),
683 None::<&()>,
684 None,
685 )
686 .await?;
687 Ok(Self::transform_invitation(result))
688 }
689
690 pub async fn create_invitation(
738 &self,
739 request: CreateInvitationRequest,
740 ) -> Result<CreateInvitationResponse, VortexError> {
741 let transformed = Self::transform_create_request(request);
742 self.api_request("POST", "/api/v1/invitations", Some(&transformed), None)
743 .await
744 }
745
746 pub async fn get_autojoin_domains(
774 &self,
775 scope_type: &str,
776 scope: &str,
777 ) -> Result<AutojoinDomainsResponse, VortexError> {
778 let encoded_scope_type = urlencoding::encode(scope_type);
779 let encoded_scope = urlencoding::encode(scope);
780 let path = format!(
781 "/api/v1/invitations/by-scope/{}/{}/autojoin",
782 encoded_scope_type, encoded_scope
783 );
784 self.api_request::<AutojoinDomainsResponse, ()>("GET", &path, None, None)
785 .await
786 }
787
788 pub async fn sync_internal_invitation(
846 &self,
847 request: &SyncInternalInvitationRequest,
848 ) -> Result<SyncInternalInvitationResponse, VortexError> {
849 self.api_request(
850 "POST",
851 "/api/v1/invitations/sync-internal-invitation",
852 Some(request),
853 None,
854 )
855 .await
856 }
857
858 pub async fn configure_autojoin(
859 &self,
860 request: &ConfigureAutojoinRequest,
861 ) -> Result<AutojoinDomainsResponse, VortexError> {
862 let mut result: AutojoinDomainsResponse = self.api_request("POST", "/api/v1/invitations/autojoin", Some(request), None)
863 .await?;
864 if let Some(inv) = result.invitation.take() {
865 result.invitation = Some(Self::transform_invitation(inv));
866 }
867 Ok(result)
868 }
869
870 async fn api_request<T, B>(
871 &self,
872 method: &str,
873 path: &str,
874 body: Option<&B>,
875 query_params: Option<HashMap<&str, &str>>,
876 ) -> Result<T, VortexError>
877 where
878 T: serde::de::DeserializeOwned,
879 B: serde::Serialize,
880 {
881 let url = format!("{}{}", self.base_url, path);
882
883 let mut request = match method {
884 "GET" => self.http_client.get(&url),
885 "POST" => self.http_client.post(&url),
886 "PUT" => self.http_client.put(&url),
887 "DELETE" => self.http_client.delete(&url),
888 _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
889 };
890
891 request = request
893 .header("Content-Type", "application/json")
894 .header("x-api-key", &self.api_key)
895 .header("User-Agent", format!("vortex-rust-sdk/{}", env!("CARGO_PKG_VERSION")))
896 .header("x-vortex-sdk-name", "vortex-rust-sdk")
897 .header("x-vortex-sdk-version", env!("CARGO_PKG_VERSION"));
898
899 if let Some(params) = query_params {
901 request = request.query(¶ms);
902 }
903
904 if let Some(b) = body {
906 request = request.json(b);
907 }
908
909 let response = request
910 .send()
911 .await
912 .map_err(|e| VortexError::HttpError(e.to_string()))?;
913
914 if !response.status().is_success() {
915 let status = response.status();
916 let error_text = response
917 .text()
918 .await
919 .unwrap_or_else(|_| "Unknown error".to_string());
920 return Err(VortexError::ApiError(format!(
921 "API request failed: {} - {}",
922 status, error_text
923 )));
924 }
925
926 let text = response
927 .text()
928 .await
929 .map_err(|e| VortexError::HttpError(e.to_string()))?;
930
931 if text.is_empty() {
933 return serde_json::from_str("{}")
934 .map_err(|e| VortexError::SerializationError(e.to_string()));
935 }
936
937 serde_json::from_str(&text)
938 .map_err(|e| VortexError::SerializationError(e.to_string()))
939 }
940
941 #[deprecated(since = "0.1.0", note = "Use get_invitations_by_scope instead")]
945 pub async fn get_invitations_by_group(
946 &self,
947 group_type: &str,
948 group: &str,
949 ) -> Result<Vec<Invitation>, VortexError> {
950 self.get_invitations_by_scope(group_type, group).await
951 }
952
953 #[deprecated(since = "0.1.0", note = "Use delete_invitations_by_scope instead")]
955 pub async fn delete_invitations_by_group(
956 &self,
957 group_type: &str,
958 group: &str,
959 ) -> Result<(), VortexError> {
960 self.delete_invitations_by_scope(group_type, group).await
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 fn test_client() -> VortexClient {
969 VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.testkey".to_string())
972 }
973
974 #[test]
975 fn test_parse_expires_in_seconds() {
976 let client = test_client();
977 assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(300)).unwrap(), 300);
978 assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(3600)).unwrap(), 3600);
979 }
980
981 #[test]
982 fn test_parse_expires_in_zero_seconds_rejected() {
983 let client = test_client();
984 let result = client.parse_expires_in(&ExpiresIn::Seconds(0));
985 assert!(result.is_err());
986 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
987 }
988
989 #[test]
990 fn test_parse_expires_in_minutes() {
991 let client = test_client();
992 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("5m".to_string())).unwrap(), 300);
993 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("60m".to_string())).unwrap(), 3600);
994 }
995
996 #[test]
997 fn test_parse_expires_in_hours() {
998 let client = test_client();
999 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1h".to_string())).unwrap(), 3600);
1000 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("24h".to_string())).unwrap(), 86400);
1001 }
1002
1003 #[test]
1004 fn test_parse_expires_in_days() {
1005 let client = test_client();
1006 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1d".to_string())).unwrap(), 86400);
1007 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("7d".to_string())).unwrap(), 604800);
1008 }
1009
1010 #[test]
1011 fn test_parse_expires_in_zero_duration_rejected() {
1012 let client = test_client();
1013
1014 let result = client.parse_expires_in(&ExpiresIn::Duration("0m".to_string()));
1015 assert!(result.is_err());
1016 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1017
1018 let result = client.parse_expires_in(&ExpiresIn::Duration("0h".to_string()));
1019 assert!(result.is_err());
1020 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1021
1022 let result = client.parse_expires_in(&ExpiresIn::Duration("0d".to_string()));
1023 assert!(result.is_err());
1024 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1025 }
1026
1027 #[test]
1028 fn test_parse_expires_in_overflow_rejected() {
1029 let client = test_client();
1030
1031 let large_value = format!("{}m", u64::MAX);
1033 let result = client.parse_expires_in(&ExpiresIn::Duration(large_value));
1034 assert!(result.is_err());
1035 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1036
1037 let result = client.parse_expires_in(&ExpiresIn::Duration("999999999999999999999d".to_string()));
1039 assert!(result.is_err());
1040 }
1041
1042 #[test]
1043 fn test_parse_expires_in_invalid_format() {
1044 let client = test_client();
1045
1046 let result = client.parse_expires_in(&ExpiresIn::Duration("5s".to_string()));
1047 assert!(result.is_err());
1048 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1049
1050 let result = client.parse_expires_in(&ExpiresIn::Duration("invalid".to_string()));
1051 assert!(result.is_err());
1052
1053 let result = client.parse_expires_in(&ExpiresIn::Duration("".to_string()));
1054 assert!(result.is_err());
1055 }
1056
1057 #[test]
1058 fn test_generate_token_basic() {
1059 let client = test_client();
1060 let payload = GenerateTokenPayload::new()
1061 .with_user(TokenUser::new("user-123"));
1062
1063 let result = client.generate_token(&payload, None);
1064 assert!(result.is_ok());
1065
1066 let token = result.unwrap();
1067 assert_eq!(token.split('.').count(), 3);
1069 }
1070
1071 #[test]
1072 fn test_generate_token_with_expires_in() {
1073 let client = test_client();
1074 let payload = GenerateTokenPayload::new()
1075 .with_user(TokenUser::new("user-123"));
1076 let options = GenerateTokenOptions::new()
1077 .with_expires_in("24h");
1078
1079 let result = client.generate_token(&payload, Some(&options));
1080 assert!(result.is_ok());
1081 }
1082
1083 #[test]
1084 fn test_expires_in_from_string() {
1085 let expires: ExpiresIn = String::from("24h").into();
1086 assert!(matches!(expires, ExpiresIn::Duration(s) if s == "24h"));
1087 }
1088
1089 #[test]
1090 fn test_expires_in_from_u32() {
1091 let expires: ExpiresIn = 300u32.into();
1092 assert!(matches!(expires, ExpiresIn::Seconds(300)));
1093 }
1094
1095 #[test]
1096 fn test_expires_in_from_i32() {
1097 let expires: ExpiresIn = 300i32.into();
1098 assert!(matches!(expires, ExpiresIn::Seconds(300)));
1099 }
1100}