1use crate::application::config::Config;
16use crate::application::http::make_http_request;
17use crate::application::rate_limiter::RateLimiter;
18use crate::constants::USER_AGENT;
19use crate::error::{AppError, AuthError};
20pub(crate) use crate::model::auth::{SecurityHeaders, SessionResponse};
21use crate::model::retry::RetryConfig;
22use reqwest::{Client, Method};
23use std::sync::Arc;
24use tokio::sync::RwLock;
25use tracing::{debug, error, info, warn};
26#[cfg(test)]
31use crate::model::auth::OAuthToken;
32#[cfg(test)]
33use chrono::Utc;
34
35pub use crate::model::auth::{Session, WebsocketInfo};
41
42#[must_use]
51fn apply_switch_headers(mut session: Session, cst: Option<&str>, xst: Option<&str>) -> Session {
52 if let Some(cst) = cst {
53 session.cst = Some(cst.to_string());
54 }
55 if let Some(xst) = xst {
56 session.x_security_token = Some(xst.to_string());
57 }
58 session
59}
60
61#[must_use]
71fn should_switch_account(api_version: u8, configured: &str, current: &str) -> bool {
72 api_version != 3
73 && !configured.is_empty()
74 && configured != crate::constants::DEFAULT_ACCOUNT_ID
75 && configured != current
76}
77
78#[must_use]
94fn proactive_refresh_margin_secs(session: &Session) -> u64 {
95 if session.is_oauth() {
96 crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
97 } else {
98 crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
99 }
100}
101
102fn ensure_oauth_session(session: Session) -> Result<Session, AppError> {
117 if session.is_oauth() {
118 Ok(session)
119 } else {
120 error!("v3 login response did not contain an OAuth token");
121 Err(AppError::Unauthorized)
122 }
123}
124
125pub struct Auth {
134 config: Arc<Config>,
135 client: Client,
136 session: Arc<RwLock<Option<Session>>>,
137 rate_limiter: RateLimiter,
141}
142
143impl Auth {
144 pub fn try_new(config: Arc<Config>) -> Result<Self, AppError> {
158 let client = Client::builder().user_agent(USER_AGENT).build()?;
159
160 let rate_limiter = RateLimiter::new(&config.rate_limiter);
161
162 Ok(Self {
163 config,
164 client,
165 session: Arc::new(RwLock::new(None)),
166 rate_limiter,
167 })
168 }
169
170 pub async fn ws_info(&self) -> Result<WebsocketInfo, AppError> {
187 let session = self.get_session().await?;
188 Ok(session.get_websocket_info())
189 }
190
191 #[deprecated(
196 note = "use ws_info() which reuses the cached session and returns a typed error instead of a default-on-error WebsocketInfo"
197 )]
198 pub async fn get_ws_info(&self) -> WebsocketInfo {
199 self.ws_info().await.unwrap_or_default()
200 }
201
202 pub async fn get_session(&self) -> Result<Session, AppError> {
210 let session = self.session.read().await;
211
212 if let Some(sess) = session.as_ref() {
213 let margin = proactive_refresh_margin_secs(sess);
218 if sess.needs_token_refresh(Some(margin)) {
219 drop(session); debug!(margin_secs = margin, "session within refresh margin");
221 return self.refresh_token().await;
222 }
223 return Ok(sess.clone());
224 }
225
226 drop(session);
227
228 info!("No active session, logging in");
230 self.login().await
231 }
232
233 pub async fn login(&self) -> Result<Session, AppError> {
241 let api_version = self.config.api_version.unwrap_or(2);
242
243 debug!("Logging in with API v{}", api_version);
244
245 let session = if api_version == 3 {
246 self.login_oauth().await?
247 } else {
248 self.login_v2().await?
249 };
250
251 {
256 let mut sess = self.session.write().await;
257 *sess = Some(session.clone());
258 }
259
260 info!("✓ Login successful, account: {}", session.account_id);
261
262 if api_version != 3 {
270 let configured = self.config.credentials.account_id.clone();
271 if should_switch_account(api_version, &configured, &session.account_id) {
272 info!("selecting configured account after v2 login");
273 return Box::pin(self.switch_account(&configured, None)).await;
278 }
279 }
280
281 Ok(session)
282 }
283
284 async fn login_v2(&self) -> Result<Session, AppError> {
286 let url = format!("{}/session", self.config.rest_api.base_url);
287
288 let body = serde_json::json!({
289 "identifier": self.config.credentials.username,
290 "password": self.config.credentials.password,
291 });
292
293 debug!("Sending v2 login request to: {}", url);
294
295 let headers = vec![
296 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
297 ("Content-Type", "application/json"),
298 ("Version", "2"),
299 ];
300
301 let response = make_http_request(
302 &self.client,
303 &self.rate_limiter,
304 Method::POST,
305 &url,
306 headers,
307 &Some(body),
308 RetryConfig::default(),
309 )
310 .await?;
311
312 let cst: String = match response
314 .headers()
315 .get("CST")
316 .and_then(|v| v.to_str().ok())
317 .map(String::from)
318 {
319 Some(token) => token,
320 None => {
321 error!("missing cst header in login response");
324 return Err(AuthError::MissingSessionToken("cst".to_string()).into());
325 }
326 };
327 let x_security_token: String = match response
328 .headers()
329 .get("X-SECURITY-TOKEN")
330 .and_then(|v| v.to_str().ok())
331 .map(String::from)
332 {
333 Some(token) => token,
334 None => {
335 error!("missing x-security-token header in login response");
338 return Err(AuthError::MissingSessionToken("x-security-token".to_string()).into());
339 }
340 };
341
342 let x_ig_api_key: String = response
343 .headers()
344 .get("X-IG-API-KEY")
345 .and_then(|v| v.to_str().ok())
346 .map(String::from)
347 .unwrap_or_else(|| self.config.credentials.api_key.clone());
348
349 let security_headers: SecurityHeaders = SecurityHeaders {
350 cst,
351 x_security_token,
352 x_ig_api_key,
353 };
354
355 let body_text = response.text().await.map_err(|e| {
357 error!("Failed to read response body: {}", e);
358 AppError::Network(e)
359 })?;
360 debug!("Login response body length: {} bytes", body_text.len());
361
362 let mut response: SessionResponse = serde_json::from_str(&body_text).map_err(|e| {
364 error!(
366 endpoint = %url,
367 body_len = body_text.len(),
368 "failed to parse login response: {}",
369 e
370 );
371 AppError::Deserialization(format!("Failed to parse login response: {}", e))
372 })?;
373 let session = response.get_session_v2(&security_headers);
374
375 Ok(session)
376 }
377
378 async fn login_oauth(&self) -> Result<Session, AppError> {
380 let url = format!("{}/session", self.config.rest_api.base_url);
381
382 let body = serde_json::json!({
383 "identifier": self.config.credentials.username,
384 "password": self.config.credentials.password,
385 });
386
387 debug!("Sending OAuth login request to: {}", url);
388 let headers = vec![
389 ("X-IG-API-KEY", self.config.credentials.api_key.as_str()),
390 ("Content-Type", "application/json"),
391 ("Version", "3"),
392 ];
393
394 let response = make_http_request(
395 &self.client,
396 &self.rate_limiter,
397 Method::POST,
398 &url,
399 headers,
400 &Some(body),
401 RetryConfig::default(),
402 )
403 .await?;
404
405 let response: SessionResponse = response.json().await?;
406 let mut session = response.get_session();
407 if session.account_id != self.config.credentials.account_id {
408 session.account_id = self.config.credentials.account_id.clone();
409 };
410
411 ensure_oauth_session(session)
414 }
415
416 pub async fn refresh_token(&self) -> Result<Session, AppError> {
438 let current_session = {
439 let session = self.session.read().await;
440 session.clone()
441 };
442
443 if let Some(sess) = current_session {
444 let margin = proactive_refresh_margin_secs(&sess);
448 if sess.is_expired(Some(margin)) {
449 debug!(
450 margin_secs = margin,
451 "session within refresh margin, logging in"
452 );
453 self.login().await
454 } else {
455 Ok(sess)
456 }
457 } else {
458 warn!("No session to refresh, performing login");
459 self.login().await
460 }
461 }
462
463 pub async fn force_refresh(&self) -> Result<Session, AppError> {
488 debug!("forcing re-authentication, ignoring local expiry");
489 self.login().await
490 }
491
492 pub async fn switch_account(
502 &self,
503 account_id: &str,
504 default_account: Option<bool>,
505 ) -> Result<Session, AppError> {
506 let current_session = self.get_session().await?;
507 if matches!(current_session.api_version, 3) {
508 return Err(AppError::InvalidInput(
509 "Cannot switch accounts with OAuth".to_string(),
510 ));
511 }
512
513 if current_session.account_id == account_id {
514 debug!("Already on account {}", account_id);
515 return Ok(current_session);
516 }
517
518 info!("Switching to account: {}", account_id);
519
520 let url = format!("{}/session", self.config.rest_api.base_url);
521
522 let mut body = serde_json::json!({
523 "accountId": account_id,
524 });
525
526 if let Some(default) = default_account {
527 body["defaultAccount"] = serde_json::json!(default);
528 }
529
530 let api_key = self.config.credentials.api_key.clone();
535 let cst;
536 let x_security_token;
537
538 let mut headers = vec![
539 ("X-IG-API-KEY", api_key.as_str()),
540 ("Content-Type", "application/json"),
541 ("Version", "1"),
542 ];
543
544 if let Some(cst_val) = ¤t_session.cst {
545 cst = cst_val.clone();
546 headers.push(("CST", cst.as_str()));
547 }
548 if let Some(token_val) = ¤t_session.x_security_token {
549 x_security_token = token_val.clone();
550 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
551 }
552
553 let response = make_http_request(
554 &self.client,
555 &self.rate_limiter,
556 Method::PUT,
557 &url,
558 headers,
559 &Some(body),
560 RetryConfig::default(),
561 )
562 .await?;
563
564 let new_cst = response
568 .headers()
569 .get("CST")
570 .and_then(|v| v.to_str().ok())
571 .map(String::from);
572 let new_x_security_token = response
573 .headers()
574 .get("X-SECURITY-TOKEN")
575 .and_then(|v| v.to_str().ok())
576 .map(String::from);
577
578 if new_x_security_token.is_none() {
582 warn!("switch response carried no X-SECURITY-TOKEN; keeping the previous token");
583 }
584
585 let mut new_session = apply_switch_headers(
588 current_session.clone(),
589 new_cst.as_deref(),
590 new_x_security_token.as_deref(),
591 );
592 new_session.account_id = account_id.to_string();
593
594 {
595 let mut session = self.session.write().await;
596 *session = Some(new_session.clone());
597 }
598
599 info!("✓ Switched to account: {}", account_id);
600 Ok(new_session)
601 }
602
603 pub async fn logout(&self) -> Result<(), AppError> {
624 info!("Logging out");
625
626 let current_session = {
629 let session = self.session.read().await;
630 session.clone()
631 };
632
633 let server_result = match current_session {
634 Some(sess) => self.revoke_session(&sess).await,
635 None => Ok(()),
636 };
637
638 {
641 let mut session = self.session.write().await;
642 *session = None;
643 }
644
645 match server_result {
646 Ok(()) => {
647 info!("✓ Logged out successfully");
648 Ok(())
649 }
650 Err(e) => {
651 error!("server-side logout failed: {}", e);
654 Err(e)
655 }
656 }
657 }
658
659 async fn revoke_session(&self, session: &Session) -> Result<(), AppError> {
669 let url = format!("{}/session", self.config.rest_api.base_url);
670 let api_key = self.config.credentials.api_key.clone();
671
672 let auth_header_value;
673 let cst;
674 let x_security_token;
675
676 let mut headers = vec![
677 ("X-IG-API-KEY", api_key.as_str()),
678 ("Content-Type", "application/json"),
679 ("Version", "1"),
680 ];
681
682 if let Some(oauth) = &session.oauth_token {
683 auth_header_value = format!("Bearer {}", oauth.access_token);
684 headers.push(("Authorization", auth_header_value.as_str()));
685 headers.push(("IG-ACCOUNT-ID", session.account_id.as_str()));
686 } else {
687 if let Some(cst_val) = &session.cst {
688 cst = cst_val.clone();
689 headers.push(("CST", cst.as_str()));
690 }
691 if let Some(token_val) = &session.x_security_token {
692 x_security_token = token_val.clone();
693 headers.push(("X-SECURITY-TOKEN", x_security_token.as_str()));
694 }
695 }
696
697 match make_http_request(
698 &self.client,
699 &self.rate_limiter,
700 Method::DELETE,
701 &url,
702 headers,
703 &None::<()>,
704 RetryConfig::default(),
705 )
706 .await
707 {
708 Ok(_) => Ok(()),
709 Err(AppError::Unauthorized | AppError::OAuthTokenExpired) => {
712 debug!("session already invalid server-side; treating as logged out");
713 Ok(())
714 }
715 Err(e) => Err(e),
716 }
717 }
718}
719
720#[cfg(test)]
721mod session_lifecycle_tests {
722 use super::*;
723
724 fn v2_session(cst: Option<&str>, xst: Option<&str>) -> Session {
725 Session {
726 account_id: "ACC-OLD".to_string(),
727 client_id: "CLIENT1".to_string(),
728 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
729 cst: cst.map(String::from),
730 x_security_token: xst.map(String::from),
731 oauth_token: None,
732 api_version: 2,
733 expires_at: 123,
734 }
735 }
736
737 #[test]
738 fn test_apply_switch_headers_replaces_xst_and_keeps_other_fields() {
739 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
740 let updated = apply_switch_headers(session, None, Some("NEW-XST"));
742
743 assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
744 assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
746 assert_eq!(updated.account_id, "ACC-OLD");
748 assert_eq!(updated.expires_at, 123);
749 assert_eq!(updated.api_version, 2);
750 }
751
752 #[test]
753 fn test_apply_switch_headers_updates_both_tokens() {
754 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
755 let updated = apply_switch_headers(session, Some("NEW-CST"), Some("NEW-XST"));
756
757 assert_eq!(updated.cst.as_deref(), Some("NEW-CST"));
758 assert_eq!(updated.x_security_token.as_deref(), Some("NEW-XST"));
759 }
760
761 #[test]
762 fn test_apply_switch_headers_none_preserves_existing_tokens() {
763 let session = v2_session(Some("OLD-CST"), Some("OLD-XST"));
764 let updated = apply_switch_headers(session, None, None);
766
767 assert_eq!(updated.cst.as_deref(), Some("OLD-CST"));
768 assert_eq!(updated.x_security_token.as_deref(), Some("OLD-XST"));
769 }
770
771 #[test]
772 fn test_should_switch_account_guards_the_sentinel_and_v3() {
773 use crate::constants::DEFAULT_ACCOUNT_ID;
774
775 assert!(should_switch_account(2, "ACC-B", "ACC-A"));
777 assert!(!should_switch_account(2, DEFAULT_ACCOUNT_ID, "ACC-A"));
779 assert!(!should_switch_account(2, "", "ACC-A"));
781 assert!(!should_switch_account(2, "ACC-A", "ACC-A"));
783 assert!(!should_switch_account(3, "ACC-B", "ACC-A"));
785 }
786
787 #[tokio::test]
788 async fn test_ws_info_uses_cached_session_without_login() {
789 let auth =
790 Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
791
792 let expires_at = (Utc::now().timestamp() + 3600) as u64;
796 let seeded = Session {
797 account_id: "ACC123".to_string(),
798 client_id: "CLIENT1".to_string(),
799 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
800 cst: Some("CST-TOKEN".to_string()),
801 x_security_token: Some("XST-TOKEN".to_string()),
802 oauth_token: None,
803 api_version: 2,
804 expires_at,
805 };
806 {
807 let mut guard = auth.session.write().await;
808 *guard = Some(seeded);
809 }
810
811 let ws = match auth.ws_info().await {
812 Ok(ws) => ws,
813 Err(e) => panic!("ws_info should return Ok for a cached session: {e}"),
814 };
815
816 assert_eq!(ws.account_id, "ACC123");
819 assert_eq!(ws.cst.as_deref(), Some("CST-TOKEN"));
820 assert_eq!(ws.x_security_token.as_deref(), Some("XST-TOKEN"));
821 assert!(ws.server.contains("demo-apd.marketdatasystems.com"));
822 }
823}
824
825#[cfg(test)]
826mod expiry_and_refresh_tests {
827 use super::*;
828
829 fn v2_session(expires_at: u64) -> Session {
830 Session {
831 account_id: "ACC123".to_string(),
832 client_id: "CLIENT1".to_string(),
833 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
834 cst: Some("CST-TOKEN".to_string()),
835 x_security_token: Some("XST-TOKEN".to_string()),
836 oauth_token: None,
837 api_version: 2,
838 expires_at,
839 }
840 }
841
842 fn v3_session(expires_at: u64) -> Session {
843 Session {
844 account_id: "ACC123".to_string(),
845 client_id: "CLIENT1".to_string(),
846 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
847 cst: None,
848 x_security_token: None,
849 oauth_token: Some(OAuthToken {
850 access_token: "ACCESS".to_string(),
851 refresh_token: "REFRESH".to_string(),
852 scope: "read write".to_string(),
853 token_type: "Bearer".to_string(),
854 expires_in: "60".to_string(),
855 created_at: Utc::now(),
856 }),
857 api_version: 3,
858 expires_at,
859 }
860 }
861
862 #[test]
863 fn test_seconds_until_expiry_expired_session_returns_zero() {
864 let past = u64::try_from(Utc::now().timestamp())
866 .unwrap_or(0)
867 .saturating_sub(100);
868 let session = v2_session(past);
869 assert_eq!(session.seconds_until_expiry(), 0);
870 }
871
872 #[test]
873 fn test_seconds_until_expiry_valid_session_is_positive() {
874 let future = u64::try_from(Utc::now().timestamp())
875 .unwrap_or(0)
876 .saturating_add(3600);
877 let session = v2_session(future);
878 assert!(session.seconds_until_expiry() > 3500);
880 }
881
882 #[test]
883 fn test_time_until_expiry_expired_session_is_zero() {
884 let past = u64::try_from(Utc::now().timestamp())
885 .unwrap_or(0)
886 .saturating_sub(100);
887 let session = v2_session(past);
888 assert_eq!(session.time_until_expiry(), std::time::Duration::ZERO);
889 }
890
891 #[test]
892 fn test_is_expired_large_margin_does_not_underflow() {
893 let session = v2_session(1);
896 assert!(session.is_expired(Some(u64::MAX)));
897 assert!(session.is_expired(Some(1000)));
898 }
899
900 #[test]
901 fn test_is_expired_valid_session_within_margin_still_valid() {
902 let future = u64::try_from(Utc::now().timestamp())
903 .unwrap_or(0)
904 .saturating_add(3600);
905 let session = v2_session(future);
906 assert!(!session.is_expired(Some(60)));
907 }
908
909 #[test]
910 fn test_proactive_refresh_margin_v3_is_small_v2_is_large() {
911 let v3 = v3_session(0);
912 let v2 = v2_session(0);
913 assert_eq!(
914 proactive_refresh_margin_secs(&v3),
915 crate::constants::PROACTIVE_REFRESH_MARGIN_V3_SECS
916 );
917 assert_eq!(
918 proactive_refresh_margin_secs(&v2),
919 crate::constants::PROACTIVE_REFRESH_MARGIN_V2_SECS
920 );
921 assert!(proactive_refresh_margin_secs(&v3) < proactive_refresh_margin_secs(&v2));
923 }
924
925 #[tokio::test]
926 async fn test_refresh_token_returns_cached_valid_session_without_login() {
927 let auth =
931 Auth::try_new(Arc::new(Config::default())).expect("auth construction should succeed");
932 let future = u64::try_from(Utc::now().timestamp())
933 .unwrap_or(0)
934 .saturating_add(3600);
935 let seeded = v2_session(future);
936 {
937 let mut guard = auth.session.write().await;
938 *guard = Some(seeded);
939 }
940
941 let refreshed = match auth.refresh_token().await {
942 Ok(session) => session,
943 Err(e) => panic!("refresh_token should return the cached session: {e}"),
944 };
945 assert_eq!(refreshed.account_id, "ACC123");
947 assert_eq!(refreshed.cst.as_deref(), Some("CST-TOKEN"));
948 assert_eq!(refreshed.expires_at, future);
949 }
950
951 #[test]
952 fn test_force_refresh_is_public_and_present() {
953 let _ = Auth::force_refresh;
957 }
958}
959
960#[cfg(test)]
961mod oauth_login_guard_tests {
962 use super::*;
963
964 const V2_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":21065.86,"deposit":3033.31,"profitLoss":-285.27,"available":16659.01},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"ZZZZZ","lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","accounts":[{"accountId":"Z405P5","accountName":"Turbo24","preferred":false,"accountType":"PHYSICAL"},{"accountId":"ZHJ5N","accountName":"DEMO_A","preferred":false,"accountType":"CFD"},{"accountId":"ZZZZZ","accountName":"Opciones","preferred":true,"accountType":"CFD"}],"clientId":"101290216","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
969
970 #[test]
971 fn test_ensure_oauth_session_v2_body_on_v3_path_yields_unauthorized()
972 -> Result<(), serde_json::Error> {
973 let response: SessionResponse = serde_json::from_str(V2_BODY)?;
976 let session = response.get_session();
977 assert!(!session.is_oauth());
979
980 match ensure_oauth_session(session) {
983 Err(AppError::Unauthorized) => Ok(()),
984 other => panic!("expected AppError::Unauthorized, got {other:?}"),
985 }
986 }
987
988 #[test]
989 fn test_ensure_oauth_session_oauth_body_passes_through() {
990 let session = Session {
992 account_id: "ACC123".to_string(),
993 client_id: "CLIENT1".to_string(),
994 lightstreamer_endpoint: "demo-apd.marketdatasystems.com".to_string(),
995 cst: None,
996 x_security_token: None,
997 oauth_token: Some(OAuthToken {
998 access_token: "ACCESS".to_string(),
999 refresh_token: "REFRESH".to_string(),
1000 scope: "read write".to_string(),
1001 token_type: "Bearer".to_string(),
1002 expires_in: "60".to_string(),
1003 created_at: Utc::now(),
1004 }),
1005 api_version: 3,
1006 expires_at: 0,
1007 };
1008 assert!(ensure_oauth_session(session).is_ok());
1009 }
1010}
1011
1012#[cfg(test)]
1013mod redaction_tests {
1014 use super::*;
1015
1016 fn secret_session() -> Session {
1017 Session {
1018 account_id: "ACC123".to_string(),
1019 client_id: "CLIENT1".to_string(),
1020 lightstreamer_endpoint: "https://ls.example.com".to_string(),
1021 cst: Some("SECRET-CST-VALUE".to_string()),
1022 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1023 oauth_token: Some(OAuthToken {
1024 access_token: "SECRET-ACCESS-VALUE".to_string(),
1025 refresh_token: "SECRET-REFRESH-VALUE".to_string(),
1026 scope: "read write".to_string(),
1027 token_type: "Bearer".to_string(),
1028 expires_in: "60".to_string(),
1029 created_at: chrono::Utc::now(),
1030 }),
1031 api_version: 3,
1032 expires_at: 0,
1033 }
1034 }
1035
1036 #[test]
1037 fn test_session_debug_redacts_tokens() {
1038 let session = secret_session();
1039 let rendered = format!("{session:?}");
1040
1041 assert!(!rendered.contains("SECRET-CST-VALUE"));
1042 assert!(!rendered.contains("SECRET-XST-VALUE"));
1043 assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
1044 assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
1045 assert!(rendered.contains("<redacted>"));
1046 assert!(rendered.contains("ACC123"));
1048 assert!(rendered.contains("ls.example.com"));
1049 }
1050
1051 #[test]
1052 fn test_websocket_info_debug_redacts_tokens() {
1053 let ws = WebsocketInfo {
1054 server: "https://ls.example.com/lightstreamer".to_string(),
1055 cst: Some("SECRET-CST-VALUE".to_string()),
1056 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1057 account_id: "ACC123".to_string(),
1058 };
1059 let rendered = format!("{ws:?}");
1060
1061 assert!(!rendered.contains("SECRET-CST-VALUE"));
1062 assert!(!rendered.contains("SECRET-XST-VALUE"));
1063 assert!(rendered.contains("Some(<redacted>)"));
1064 assert!(rendered.contains("ACC123"));
1065 assert!(rendered.contains("ls.example.com"));
1066 }
1067
1068 #[test]
1069 fn test_websocket_info_display_redacts_tokens() {
1070 let ws = WebsocketInfo {
1071 server: "https://ls.example.com/lightstreamer".to_string(),
1072 cst: Some("SECRET-CST-VALUE".to_string()),
1073 x_security_token: Some("SECRET-XST-VALUE".to_string()),
1074 account_id: "ACC123".to_string(),
1075 };
1076 let rendered = format!("{ws}");
1077
1078 assert!(!rendered.contains("SECRET-CST-VALUE"));
1079 assert!(!rendered.contains("SECRET-XST-VALUE"));
1080 assert!(rendered.contains("<redacted>"));
1081 assert!(rendered.contains("ACC123"));
1082
1083 let ws_none = WebsocketInfo {
1085 server: "https://ls.example.com/lightstreamer".to_string(),
1086 cst: None,
1087 x_security_token: None,
1088 account_id: "ACC123".to_string(),
1089 };
1090 assert!(format!("{ws_none}").contains("None"));
1091 }
1092}