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> {
146 let (kid, signing_key) = self.parse_and_derive_key()?;
147
148 let mut canonical = serde_json::Map::new();
150 canonical.insert("userId".to_string(), json!(user.id));
151 canonical.insert("userEmail".to_string(), json!(&user.email));
152 let user_name = user.name.as_ref().or(user.user_name.as_ref());
154 if let Some(name) = user_name {
155 canonical.insert("name".to_string(), json!(name));
156 }
157 let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
158 if let Some(avatar) = user_avatar_url {
159 canonical.insert("avatarUrl".to_string(), json!(avatar));
160 }
161 if let Some(ref scopes) = user.admin_scopes {
162 if !scopes.is_empty() {
163 canonical.insert("adminScopes".to_string(), json!(scopes));
164 }
165 }
166 if let Some(ref domains) = user.allowed_email_domains {
167 if !domains.is_empty() {
168 canonical.insert("allowedEmailDomains".to_string(), json!(domains));
169 }
170 }
171 if let Some(ref extra) = user.extra {
173 for (k, v) in extra {
174 if !canonical.contains_key(k) {
175 canonical.insert(k.clone(), v.clone());
176 }
177 }
178 }
179
180 let canonical_json = serde_json::to_string(&serde_json::Value::Object(canonical))
182 .map_err(|e| VortexError::CryptoError(format!("Failed to serialize canonical payload: {}", e)))?;
183
184 let mut mac = HmacSha256::new_from_slice(&signing_key)
185 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
186 mac.update(canonical_json.as_bytes());
187 let digest = hex::encode(mac.finalize().into_bytes());
188
189 Ok(format!("{}:{}", kid, digest))
190 }
191
192 fn parse_and_derive_key(&self) -> Result<(String, Vec<u8>), VortexError> {
193 let parts: Vec<&str> = self.api_key.split('.').collect();
194 if parts.len() != 3 || parts[0] != "VRTX" {
195 return Err(VortexError::InvalidApiKey("Invalid API key format".to_string()));
196 }
197
198 let uuid_bytes = URL_SAFE_NO_PAD
199 .decode(parts[1])
200 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode key ID: {}", e)))?;
201 let kid = Uuid::from_slice(&uuid_bytes)
202 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to parse UUID: {}", e)))?
203 .to_string();
204
205 let mut mac = HmacSha256::new_from_slice(parts[2].as_bytes())
206 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
207 mac.update(kid.as_bytes());
208 let signing_key = mac.finalize().into_bytes().to_vec();
209
210 Ok((kid, signing_key))
211 }
212
213 fn parse_expires_in(&self, expires_in: &ExpiresIn) -> Result<u64, VortexError> {
215 match expires_in {
216 ExpiresIn::Seconds(s) => {
217 if *s == 0 {
218 return Err(VortexError::InvalidRequest(
219 "expires_in must be a positive number of seconds".to_string(),
220 ));
221 }
222 Ok(*s)
223 }
224 ExpiresIn::Duration(s) => {
225 static DURATION_RE: OnceLock<regex::Regex> = OnceLock::new();
227 let re = DURATION_RE.get_or_init(|| {
228 regex::Regex::new(r"^(\d+)(m|h|d)$").expect("Invalid regex pattern")
229 });
230
231 if let Some(caps) = re.captures(s) {
232 let value: u64 = caps[1].parse().map_err(|_| {
233 VortexError::InvalidRequest(format!(
234 "Invalid expires_in format: \"{}\". Value is too large.",
235 s
236 ))
237 })?;
238
239 if value == 0 {
241 return Err(VortexError::InvalidRequest(
242 "expires_in must be a positive duration".to_string(),
243 ));
244 }
245
246 let unit = &caps[2];
247 let seconds = match unit {
248 "m" => value.checked_mul(60),
249 "h" => value.checked_mul(3600),
250 "d" => value.checked_mul(86400),
251 _ => {
252 return Err(VortexError::InvalidRequest(format!(
253 "Unknown time unit: {}",
254 unit
255 )))
256 }
257 }
258 .ok_or_else(|| {
259 VortexError::InvalidRequest(format!(
260 "expires_in value is too large: \"{}\"",
261 s
262 ))
263 })?;
264
265 Ok(seconds)
266 } else {
267 Err(VortexError::InvalidRequest(format!(
268 "Invalid expires_in format: \"{}\". Use \"5m\", \"1h\", \"24h\", \"7d\" or seconds.",
269 s
270 )))
271 }
272 }
273 }
274 }
275
276 pub fn generate_token(
285 &self,
286 payload: &GenerateTokenPayload,
287 options: Option<&GenerateTokenOptions>,
288 ) -> Result<String, VortexError> {
289 if payload.user.is_none() || payload.user.as_ref().map(|u| u.id.is_empty()).unwrap_or(true) {
291 eprintln!("[Vortex SDK] Warning: signing payload without user.id means invitations won't be securely attributed.");
292 }
293
294 let (kid, signing_key) = self.parse_and_derive_key()?;
295
296 let expires_in_seconds = if let Some(opts) = options {
298 if let Some(ref exp) = opts.expires_in {
299 self.parse_expires_in(exp)?
300 } else {
301 2592000
302 }
303 } else {
304 2592000
305 };
306
307 let now = SystemTime::now()
308 .duration_since(UNIX_EPOCH)
309 .unwrap()
310 .as_secs();
311 let exp = now.checked_add(expires_in_seconds).ok_or_else(|| {
312 VortexError::InvalidRequest(
313 "expires_in is too large and would overflow the expiration timestamp".to_string(),
314 )
315 })?;
316
317 let header = json!({
319 "alg": "HS256",
320 "typ": "JWT",
321 "kid": kid
322 });
323
324 let mut jwt_payload = serde_json::to_value(payload)
326 .map_err(|e| VortexError::CryptoError(format!("Failed to serialize payload: {}", e)))?;
327 if let Some(obj) = jwt_payload.as_object_mut() {
328 obj.insert("iat".to_string(), json!(now));
329 obj.insert("exp".to_string(), json!(exp));
330 }
331
332 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
334 let payload_b64 = URL_SAFE_NO_PAD.encode(jwt_payload.to_string().as_bytes());
335
336 let to_sign = format!("{}.{}", header_b64, payload_b64);
338 let mut mac = HmacSha256::new_from_slice(&signing_key)
339 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
340 mac.update(to_sign.as_bytes());
341 let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
342
343 Ok(format!("{}.{}", to_sign, signature))
344 }
345
346 pub fn generate_jwt(
347 &self,
348 user: &User,
349 extra: Option<HashMap<String, serde_json::Value>>,
350 ) -> Result<String, VortexError> {
351 self.generate_jwt_with_options(user, extra, None)
352 }
353
354 pub fn generate_jwt_with_options(
355 &self,
356 user: &User,
357 extra: Option<HashMap<String, serde_json::Value>>,
358 options: Option<GenerateTokenOptions>,
359 ) -> Result<String, VortexError> {
360 let parts: Vec<&str> = self.api_key.split('.').collect();
362 if parts.len() != 3 {
363 return Err(VortexError::InvalidApiKey(
364 "Invalid API key format".to_string(),
365 ));
366 }
367
368 let prefix = parts[0];
369 let encoded_id = parts[1];
370 let key = parts[2];
371
372 if prefix != "VRTX" {
373 return Err(VortexError::InvalidApiKey(
374 "Invalid API key prefix".to_string(),
375 ));
376 }
377
378 let id_bytes = URL_SAFE_NO_PAD
380 .decode(encoded_id)
381 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
382
383 if id_bytes.len() != 16 {
384 return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
385 }
386
387 let uuid = Uuid::from_slice(&id_bytes)
388 .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
389 let uuid_str = uuid.to_string();
390
391 let now = SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .unwrap()
394 .as_secs();
395 let expires_in_seconds = match &options {
396 Some(opts) => match &opts.expires_in {
397 Some(exp) => self.parse_expires_in(exp)?,
398 None => 2592000, },
400 None => 2592000, };
402 let expires = now.checked_add(expires_in_seconds).ok_or_else(|| {
403 VortexError::InvalidRequest("Expiration time overflow".to_string())
404 })?;
405
406 let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
408 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
409 hmac.update(uuid_str.as_bytes());
410 let signing_key = hmac.finalize().into_bytes();
411
412 let header = json!({
414 "iat": now,
415 "alg": "HS256",
416 "typ": "JWT",
417 "kid": uuid_str,
418 });
419
420 let mut payload_json = json!({
422 "userId": user.id,
423 "userEmail": user.email,
424 "expires": expires,
425 });
426
427 let user_name = user.name.as_ref().or(user.user_name.as_ref());
429 if let Some(name) = user_name {
430 payload_json["name"] = json!(name);
431 }
432
433 let user_avatar_url = user.avatar_url.as_ref().or(user.user_avatar_url.as_ref());
435 if let Some(avatar_url) = user_avatar_url {
436 payload_json["avatarUrl"] = json!(avatar_url);
437 }
438
439 if let Some(ref scopes) = user.admin_scopes {
441 payload_json["adminScopes"] = json!(scopes);
442 }
443
444 if let Some(ref domains) = user.allowed_email_domains {
446 if !domains.is_empty() {
447 payload_json["allowedEmailDomains"] = json!(domains);
448 }
449 }
450
451 if let Some(extra_props) = extra {
453 for (key, value) in extra_props {
454 payload_json[key] = value;
455 }
456 }
457
458 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
460 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
461
462 let to_sign = format!("{}.{}", header_b64, payload_b64);
464 let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
465 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
466 sig_hmac.update(to_sign.as_bytes());
467 let signature = sig_hmac.finalize().into_bytes();
468 let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
469
470 Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
471 }
472
473 pub async fn get_invitations_by_target(
482 &self,
483 target_type: &str,
484 target_value: &str,
485 ) -> Result<Vec<Invitation>, VortexError> {
486 let mut params = HashMap::new();
487 params.insert("targetType", target_type);
488 params.insert("targetValue", target_value);
489
490 let response: InvitationsResponse = self
491 .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
492 .await?;
493
494 Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
495 }
496
497 pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
505 let result: Invitation = self.api_request(
506 "GET",
507 &format!("/api/v1/invitations/{}", invitation_id),
508 None::<&()>,
509 None,
510 )
511 .await?;
512 Ok(Self::transform_invitation(result))
513 }
514
515 pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
523 self.api_request::<(), ()>(
524 "DELETE",
525 &format!("/api/v1/invitations/{}", invitation_id),
526 None,
527 None,
528 )
529 .await?;
530 Ok(())
531 }
532
533 pub async fn accept_invitations(
564 &self,
565 invitation_ids: Vec<String>,
566 param: impl Into<crate::types::AcceptInvitationParam>,
567 ) -> Result<Invitation, VortexError> {
568 use crate::types::{AcceptInvitationParam, AcceptUser};
569
570 let param = param.into();
571
572 let user = match param {
574 AcceptInvitationParam::Targets(targets) => {
575 eprintln!("[Vortex SDK] DEPRECATED: Passing a vector of targets is deprecated. Use the AcceptUser format and call once per user instead.");
576
577 if targets.is_empty() {
578 return Err(VortexError::InvalidRequest("No targets provided".to_string()));
579 }
580
581 let mut last_result = None;
582 let mut last_error = None;
583
584 for target in targets {
585 let user = 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),
590 };
591
592 match Box::pin(self.accept_invitations(invitation_ids.clone(), user)).await {
593 Ok(result) => last_result = Some(result),
594 Err(e) => last_error = Some(e),
595 }
596 }
597
598 if let Some(err) = last_error {
599 return Err(err);
600 }
601
602 return last_result.ok_or_else(|| VortexError::InvalidRequest("No results".to_string()));
603 }
604 AcceptInvitationParam::Target(target) => {
605 eprintln!("[Vortex SDK] DEPRECATED: Passing an InvitationTarget is deprecated. Use the AcceptUser format instead: AcceptUser::new().with_email(\"user@example.com\")");
606
607 match target.target_type {
609 InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
610 InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
611 _ => AcceptUser::new().with_email(&target.value), }
613 }
614 AcceptInvitationParam::User(user) => user,
615 };
616
617 if user.email.is_none() && user.phone.is_none() {
619 return Err(VortexError::InvalidRequest(
620 "User must have either email or phone".to_string(),
621 ));
622 }
623
624 let body = json!({
625 "invitationIds": invitation_ids,
626 "user": user,
627 });
628
629 let result: Invitation = self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
630 .await?;
631 Ok(Self::transform_invitation(result))
632 }
633
634 pub async fn accept_invitation(
657 &self,
658 invitation_id: &str,
659 user: crate::types::AcceptUser,
660 ) -> Result<Invitation, VortexError> {
661 self.accept_invitations(vec![invitation_id.to_string()], user).await
662 }
663
664 pub async fn delete_invitations_by_scope(
673 &self,
674 scope_type: &str,
675 scope: &str,
676 ) -> Result<(), VortexError> {
677 self.api_request::<(), ()>(
678 "DELETE",
679 &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
680 None,
681 None,
682 )
683 .await?;
684 Ok(())
685 }
686
687 pub async fn get_invitations_by_scope(
696 &self,
697 scope_type: &str,
698 scope: &str,
699 ) -> Result<Vec<Invitation>, VortexError> {
700 let response: InvitationsResponse = self
701 .api_request(
702 "GET",
703 &format!("/api/v1/invitations/by-scope/{}/{}", scope_type, scope),
704 None::<&()>,
705 None,
706 )
707 .await?;
708
709 Ok(Self::transform_invitations(response.invitations.unwrap_or_default()))
710 }
711
712 pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
720 let result: Invitation = self.api_request(
721 "POST",
722 &format!("/api/v1/invitations/{}/reinvite", invitation_id),
723 None::<&()>,
724 None,
725 )
726 .await?;
727 Ok(Self::transform_invitation(result))
728 }
729
730 pub async fn create_invitation(
778 &self,
779 request: CreateInvitationRequest,
780 ) -> Result<CreateInvitationResponse, VortexError> {
781 let transformed = Self::transform_create_request(request);
782 self.api_request("POST", "/api/v1/invitations", Some(&transformed), None)
783 .await
784 }
785
786 pub async fn get_autojoin_domains(
809 &self,
810 scope_type: &str,
811 scope: &str,
812 ) -> Result<AutojoinDomainsResponse, VortexError> {
813 let encoded_scope_type = urlencoding::encode(scope_type);
814 let encoded_scope = urlencoding::encode(scope);
815 let path = format!(
816 "/api/v1/invitations/by-scope/{}/{}/autojoin",
817 encoded_scope_type, encoded_scope
818 );
819 self.api_request::<AutojoinDomainsResponse, ()>("GET", &path, None, None)
820 .await
821 }
822
823 pub async fn sync_internal_invitation(
872 &self,
873 request: &SyncInternalInvitationRequest,
874 ) -> Result<SyncInternalInvitationResponse, VortexError> {
875 self.api_request(
876 "POST",
877 "/api/v1/invitations/sync-internal-invitation",
878 Some(request),
879 None,
880 )
881 .await
882 }
883
884 pub async fn configure_autojoin(
885 &self,
886 request: &ConfigureAutojoinRequest,
887 ) -> Result<AutojoinDomainsResponse, VortexError> {
888 let mut result: AutojoinDomainsResponse = self.api_request("POST", "/api/v1/invitations/autojoin", Some(request), None)
889 .await?;
890 if let Some(inv) = result.invitation.take() {
891 result.invitation = Some(Self::transform_invitation(inv));
892 }
893 Ok(result)
894 }
895
896 async fn api_request<T, B>(
897 &self,
898 method: &str,
899 path: &str,
900 body: Option<&B>,
901 query_params: Option<HashMap<&str, &str>>,
902 ) -> Result<T, VortexError>
903 where
904 T: serde::de::DeserializeOwned,
905 B: serde::Serialize,
906 {
907 let url = format!("{}{}", self.base_url, path);
908
909 let mut request = match method {
910 "GET" => self.http_client.get(&url),
911 "POST" => self.http_client.post(&url),
912 "PUT" => self.http_client.put(&url),
913 "DELETE" => self.http_client.delete(&url),
914 _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
915 };
916
917 request = request
919 .header("Content-Type", "application/json")
920 .header("x-api-key", &self.api_key)
921 .header("User-Agent", format!("vortex-rust-sdk/{}", env!("CARGO_PKG_VERSION")))
922 .header("x-vortex-sdk-name", "vortex-rust-sdk")
923 .header("x-vortex-sdk-version", env!("CARGO_PKG_VERSION"));
924
925 if let Some(params) = query_params {
927 request = request.query(¶ms);
928 }
929
930 if let Some(b) = body {
932 request = request.json(b);
933 }
934
935 let response = request
936 .send()
937 .await
938 .map_err(|e| VortexError::HttpError(e.to_string()))?;
939
940 if !response.status().is_success() {
941 let status = response.status();
942 let error_text = response
943 .text()
944 .await
945 .unwrap_or_else(|_| "Unknown error".to_string());
946 return Err(VortexError::ApiError(format!(
947 "API request failed: {} - {}",
948 status, error_text
949 )));
950 }
951
952 let text = response
953 .text()
954 .await
955 .map_err(|e| VortexError::HttpError(e.to_string()))?;
956
957 if text.is_empty() {
959 return serde_json::from_str("{}")
960 .map_err(|e| VortexError::SerializationError(e.to_string()));
961 }
962
963 serde_json::from_str(&text)
964 .map_err(|e| VortexError::SerializationError(e.to_string()))
965 }
966
967 #[deprecated(since = "0.1.0", note = "Use get_invitations_by_scope instead")]
971 pub async fn get_invitations_by_group(
972 &self,
973 group_type: &str,
974 group: &str,
975 ) -> Result<Vec<Invitation>, VortexError> {
976 self.get_invitations_by_scope(group_type, group).await
977 }
978
979 #[deprecated(since = "0.1.0", note = "Use delete_invitations_by_scope instead")]
981 pub async fn delete_invitations_by_group(
982 &self,
983 group_type: &str,
984 group: &str,
985 ) -> Result<(), VortexError> {
986 self.delete_invitations_by_scope(group_type, group).await
987 }
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993
994 fn test_client() -> VortexClient {
995 VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.testkey".to_string())
998 }
999
1000 #[test]
1001 fn test_parse_expires_in_seconds() {
1002 let client = test_client();
1003 assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(300)).unwrap(), 300);
1004 assert_eq!(client.parse_expires_in(&ExpiresIn::Seconds(3600)).unwrap(), 3600);
1005 }
1006
1007 #[test]
1008 fn test_parse_expires_in_zero_seconds_rejected() {
1009 let client = test_client();
1010 let result = client.parse_expires_in(&ExpiresIn::Seconds(0));
1011 assert!(result.is_err());
1012 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1013 }
1014
1015 #[test]
1016 fn test_parse_expires_in_minutes() {
1017 let client = test_client();
1018 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("5m".to_string())).unwrap(), 300);
1019 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("60m".to_string())).unwrap(), 3600);
1020 }
1021
1022 #[test]
1023 fn test_parse_expires_in_hours() {
1024 let client = test_client();
1025 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1h".to_string())).unwrap(), 3600);
1026 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("24h".to_string())).unwrap(), 86400);
1027 }
1028
1029 #[test]
1030 fn test_parse_expires_in_days() {
1031 let client = test_client();
1032 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("1d".to_string())).unwrap(), 86400);
1033 assert_eq!(client.parse_expires_in(&ExpiresIn::Duration("7d".to_string())).unwrap(), 604800);
1034 }
1035
1036 #[test]
1037 fn test_parse_expires_in_zero_duration_rejected() {
1038 let client = test_client();
1039
1040 let result = client.parse_expires_in(&ExpiresIn::Duration("0m".to_string()));
1041 assert!(result.is_err());
1042 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1043
1044 let result = client.parse_expires_in(&ExpiresIn::Duration("0h".to_string()));
1045 assert!(result.is_err());
1046 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1047
1048 let result = client.parse_expires_in(&ExpiresIn::Duration("0d".to_string()));
1049 assert!(result.is_err());
1050 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1051 }
1052
1053 #[test]
1054 fn test_parse_expires_in_overflow_rejected() {
1055 let client = test_client();
1056
1057 let large_value = format!("{}m", u64::MAX);
1059 let result = client.parse_expires_in(&ExpiresIn::Duration(large_value));
1060 assert!(result.is_err());
1061 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1062
1063 let result = client.parse_expires_in(&ExpiresIn::Duration("999999999999999999999d".to_string()));
1065 assert!(result.is_err());
1066 }
1067
1068 #[test]
1069 fn test_parse_expires_in_invalid_format() {
1070 let client = test_client();
1071
1072 let result = client.parse_expires_in(&ExpiresIn::Duration("5s".to_string()));
1073 assert!(result.is_err());
1074 assert!(matches!(result.unwrap_err(), VortexError::InvalidRequest(_)));
1075
1076 let result = client.parse_expires_in(&ExpiresIn::Duration("invalid".to_string()));
1077 assert!(result.is_err());
1078
1079 let result = client.parse_expires_in(&ExpiresIn::Duration("".to_string()));
1080 assert!(result.is_err());
1081 }
1082
1083 #[test]
1084 fn test_generate_token_basic() {
1085 let client = test_client();
1086 let payload = GenerateTokenPayload::new()
1087 .with_user(TokenUser::new("user-123"));
1088
1089 let result = client.generate_token(&payload, None);
1090 assert!(result.is_ok());
1091
1092 let token = result.unwrap();
1093 assert_eq!(token.split('.').count(), 3);
1095 }
1096
1097 #[test]
1098 fn test_generate_token_with_expires_in() {
1099 let client = test_client();
1100 let payload = GenerateTokenPayload::new()
1101 .with_user(TokenUser::new("user-123"));
1102 let options = GenerateTokenOptions::new()
1103 .with_expires_in("24h");
1104
1105 let result = client.generate_token(&payload, Some(&options));
1106 assert!(result.is_ok());
1107 }
1108
1109 #[test]
1110 fn test_expires_in_from_string() {
1111 let expires: ExpiresIn = String::from("24h").into();
1112 assert!(matches!(expires, ExpiresIn::Duration(s) if s == "24h"));
1113 }
1114
1115 #[test]
1116 fn test_expires_in_from_u32() {
1117 let expires: ExpiresIn = 300u32.into();
1118 assert!(matches!(expires, ExpiresIn::Seconds(300)));
1119 }
1120
1121 #[test]
1122 fn test_expires_in_from_i32() {
1123 let expires: ExpiresIn = 300i32.into();
1124 assert!(matches!(expires, ExpiresIn::Seconds(300)));
1125 }
1126}