Skip to main content

ig_client/model/
auth.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 19/10/25
5******************************************************************************/
6use crate::constants::V2_SESSION_LIFETIME_SECS;
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use tracing::warn;
11
12/// WebSocket connection information for Lightstreamer
13///
14/// Contains the necessary credentials and endpoint information
15/// to establish a WebSocket connection to IG's Lightstreamer service.
16#[derive(Clone, Default, Serialize, Deserialize)]
17pub struct WebsocketInfo {
18    /// Lightstreamer endpoint URL
19    pub server: String,
20    /// CST token for authentication (API v2)
21    pub cst: Option<String>,
22    /// X-SECURITY-TOKEN for authentication (API v2)
23    pub x_security_token: Option<String>,
24    /// Account ID for the WebSocket connection
25    pub account_id: String,
26}
27
28/// Renders an optional secret as `Some(<redacted>)` / `None`, never exposing
29/// the underlying token value in `Debug` / `Display` output or logs.
30struct RedactedOption<'a>(&'a Option<String>);
31
32impl fmt::Debug for RedactedOption<'_> {
33    #[inline]
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self.0 {
36            Some(_) => f.write_str("Some(<redacted>)"),
37            None => f.write_str("None"),
38        }
39    }
40}
41
42// Manual redacting `Debug` — `cst` / `x_security_token` are credentials and
43// must never reach logs or panics. All non-secret fields stay visible.
44impl fmt::Debug for WebsocketInfo {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_struct("WebsocketInfo")
47            .field("server", &self.server)
48            .field("cst", &RedactedOption(&self.cst))
49            .field("x_security_token", &RedactedOption(&self.x_security_token))
50            .field("account_id", &self.account_id)
51            .finish()
52    }
53}
54
55// Manual redacting `Display` — the derived `DisplaySimple` serializes via serde
56// and would leak the tokens, so it is replaced with a masking implementation.
57impl fmt::Display for WebsocketInfo {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(
60            f,
61            "WebsocketInfo {{ server: {}, cst: {:?}, x_security_token: {:?}, account_id: {} }}",
62            self.server,
63            RedactedOption(&self.cst),
64            RedactedOption(&self.x_security_token),
65            self.account_id,
66        )
67    }
68}
69
70impl WebsocketInfo {
71    /// Generates the WebSocket password for Lightstreamer authentication
72    ///
73    /// # Returns
74    /// * Password in format "CST-{cst}|XST-{token}" if both tokens are available
75    /// * Empty string if tokens are not available
76    #[must_use]
77    pub fn get_ws_password(&self) -> String {
78        match (&self.cst, &self.x_security_token) {
79            (Some(cst), Some(x_security_token)) => {
80                format!("CST-{}|XST-{}", cst, x_security_token)
81            }
82            _ => String::new(),
83        }
84    }
85}
86
87/// Session information for authenticated requests
88#[derive(Clone)]
89pub struct Session {
90    /// Account ID
91    pub account_id: String,
92    /// Client ID (for OAuth)
93    pub client_id: String,
94    /// Lightstreamer endpoint
95    pub lightstreamer_endpoint: String,
96    /// CST token (API v2)
97    pub cst: Option<String>,
98    /// X-SECURITY-TOKEN (API v2)
99    pub x_security_token: Option<String>,
100    /// OAuth token (API v3)
101    pub oauth_token: Option<OAuthToken>,
102    /// API version used
103    pub api_version: u8,
104    /// Unix timestamp when session expires (seconds since epoch)
105    /// - OAuth (v3): expires in 30 seconds
106    /// - API v2: expires in 6 hours (21600 seconds)
107    pub expires_at: u64,
108}
109
110// Manual redacting `Debug` — `cst`, `x_security_token` and the OAuth token are
111// credentials. The `OAuthToken` `Debug` impl is itself redacting, so printing
112// `oauth_token` here stays safe. All non-secret fields remain visible.
113impl fmt::Debug for Session {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("Session")
116            .field("account_id", &self.account_id)
117            .field("client_id", &self.client_id)
118            .field("lightstreamer_endpoint", &self.lightstreamer_endpoint)
119            .field("cst", &RedactedOption(&self.cst))
120            .field("x_security_token", &RedactedOption(&self.x_security_token))
121            .field("oauth_token", &self.oauth_token)
122            .field("api_version", &self.api_version)
123            .field("expires_at", &self.expires_at)
124            .finish()
125    }
126}
127
128impl Session {
129    /// Checks if this session uses OAuth authentication
130    #[must_use]
131    #[inline]
132    pub fn is_oauth(&self) -> bool {
133        self.oauth_token.is_some()
134    }
135
136    /// Checks if session is expired or will expire soon
137    ///
138    /// # Arguments
139    /// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
140    ///
141    /// # Returns
142    /// * `true` if session is expired or will expire within margin
143    /// * `false` if session is still valid
144    #[must_use]
145    #[inline]
146    pub fn is_expired(&self, margin_seconds: Option<u64>) -> bool {
147        let margin = margin_seconds.unwrap_or(60);
148        let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
149        // Saturating: a large margin against a small `expires_at` must not
150        // underflow (debug panic / bogus huge threshold in release).
151        now >= self.expires_at.saturating_sub(margin)
152    }
153
154    /// Gets the number of whole seconds until the session expires.
155    ///
156    /// # Returns
157    /// * The number of seconds remaining before expiry.
158    /// * `0` when the session is already expired — the result saturates at zero
159    ///   (a `u64` cannot be negative), so an expired session never reports a
160    ///   spuriously large remaining time.
161    #[must_use]
162    #[inline]
163    pub fn seconds_until_expiry(&self) -> u64 {
164        let now = u64::try_from(Utc::now().timestamp()).unwrap_or(0);
165        self.expires_at.saturating_sub(now)
166    }
167
168    /// Returns the time remaining until the session expires as a
169    /// [`std::time::Duration`].
170    ///
171    /// # Returns
172    /// * The remaining time before expiry.
173    /// * [`std::time::Duration::ZERO`] when the session is already expired.
174    #[must_use]
175    #[inline]
176    pub fn time_until_expiry(&self) -> std::time::Duration {
177        std::time::Duration::from_secs(self.seconds_until_expiry())
178    }
179
180    /// Checks if OAuth token needs refresh (alias for is_expired for backwards compatibility)
181    ///
182    /// # Arguments
183    /// * `margin_seconds` - Safety margin in seconds (default: 60 = 1 minute)
184    #[must_use]
185    #[inline]
186    pub fn needs_token_refresh(&self, margin_seconds: Option<u64>) -> bool {
187        self.is_expired(margin_seconds)
188    }
189
190    /// Extracts WebSocket connection information from the session
191    ///
192    /// # Returns
193    /// * `WebsocketInfo` containing endpoint and authentication tokens
194    #[must_use]
195    pub fn get_websocket_info(&self) -> WebsocketInfo {
196        // Ensure the server URL has the https:// prefix
197        let server = if self.lightstreamer_endpoint.starts_with("http://")
198            || self.lightstreamer_endpoint.starts_with("https://")
199        {
200            format!("{}/lightstreamer", self.lightstreamer_endpoint)
201        } else {
202            format!("https://{}/lightstreamer", self.lightstreamer_endpoint)
203        };
204
205        WebsocketInfo {
206            server,
207            cst: self.cst.clone(),
208            x_security_token: self.x_security_token.clone(),
209            account_id: self.account_id.clone(),
210        }
211    }
212}
213
214impl From<SessionResponse> for Session {
215    fn from(v: SessionResponse) -> Self {
216        v.get_session()
217    }
218}
219
220/// Reports whether the effective expiry (`created_at + lifetime - margin`) has
221/// been reached as of `now`, using checked `chrono` arithmetic throughout.
222///
223/// Timestamp math here is on protocol-state (session expiry), so overflow is
224/// never wrapped or silently truncated: any arithmetic overflow fails safe by
225/// reporting the token as expired (`true`), which triggers a refresh rather than
226/// trusting a bogus far-future expiry.
227#[must_use]
228#[inline]
229fn is_past_effective_expiry(
230    created_at: chrono::DateTime<Utc>,
231    lifetime_secs: i64,
232    margin_secs: i64,
233    now: chrono::DateTime<Utc>,
234) -> bool {
235    let effective = chrono::Duration::try_seconds(lifetime_secs)
236        .and_then(|lifetime| created_at.checked_add_signed(lifetime))
237        .and_then(|expiry| {
238            chrono::Duration::try_seconds(margin_secs).and_then(|m| expiry.checked_sub_signed(m))
239        });
240    match effective {
241        Some(effective_expiry) => effective_expiry <= now,
242        // Overflow computing the effective expiry: treat as expired.
243        None => true,
244    }
245}
246
247/// Response from session creation endpoint
248///
249/// This enum handles both API v2 and v3 session responses using serde's untagged feature
250#[derive(Debug, Clone, Deserialize, Serialize)]
251#[serde(untagged)]
252pub enum SessionResponse {
253    /// API v3 session response with OAuth tokens
254    V3(V3Response),
255    /// API v2 session response with CST/X-SECURITY-TOKEN
256    V2(V2Response),
257}
258
259impl SessionResponse {
260    /// Checks if this is a v3 session response
261    #[must_use]
262    #[inline]
263    pub fn is_v3(&self) -> bool {
264        matches!(self, SessionResponse::V3(_))
265    }
266
267    /// Checks if this is a v2 session response
268    #[must_use]
269    #[inline]
270    pub fn is_v2(&self) -> bool {
271        matches!(self, SessionResponse::V2(_))
272    }
273
274    /// Converts the response to a Session object
275    #[must_use]
276    pub fn get_session(&self) -> Session {
277        match self {
278            SessionResponse::V3(v) => Session {
279                account_id: v.account_id.clone(),
280                client_id: v.client_id.clone(),
281                lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
282                cst: None,
283                x_security_token: None,
284                oauth_token: Some(v.oauth_token.clone()),
285                api_version: 3,
286                expires_at: v.oauth_token.expire_at(1),
287            },
288            SessionResponse::V2(v) => {
289                let (cst, x_security_token) = match v.security_headers.as_ref() {
290                    Some(headers) => (
291                        Some(headers.cst.clone()),
292                        Some(headers.x_security_token.clone()),
293                    ),
294                    None => (None, None),
295                };
296                // Derive expiry from the token's own creation time and lifetime,
297                // staying consistent with `V2Response::is_expired` (which uses
298                // `created_at + expires_in`). Falls back to the full v2 lifetime
299                // when `expires_in` was not set on the response. Saturating math
300                // keeps a far-future / pre-epoch `created_at` from underflowing.
301                let lifetime = v.expires_in.unwrap_or(V2_SESSION_LIFETIME_SECS);
302                let created = u64::try_from(v.created_at.timestamp()).unwrap_or(0);
303                let expires_at = created.saturating_add(lifetime);
304                Session {
305                    account_id: v.current_account_id.clone(),
306                    client_id: v.client_id.clone(),
307                    lightstreamer_endpoint: v.lightstreamer_endpoint.clone(),
308                    cst,
309                    x_security_token,
310                    oauth_token: None,
311                    api_version: 2,
312                    expires_at,
313                }
314            }
315        }
316    }
317    /// Converts the response to a Session object using v2 security headers
318    ///
319    /// # Arguments
320    /// * `headers` - Security headers (CST and X-SECURITY-TOKEN)
321    pub fn get_session_v2(&mut self, headers: &SecurityHeaders) -> Session {
322        match self {
323            SessionResponse::V3(_) => {
324                warn!("Returing V3 session from V2 headers - this may be unexpected");
325                self.get_session()
326            }
327            SessionResponse::V2(v) => {
328                v.set_security_headers(headers);
329                v.expires_in = Some(V2_SESSION_LIFETIME_SECS);
330                self.get_session()
331            }
332        }
333    }
334
335    /// Checks if the session is expired
336    ///
337    /// # Arguments
338    /// * `margin_seconds` - Safety margin in seconds before actual expiration
339    #[must_use]
340    #[inline]
341    pub fn is_expired(&self, margin_seconds: u64) -> bool {
342        match self {
343            SessionResponse::V3(v) => v.oauth_token.is_expired(margin_seconds),
344            SessionResponse::V2(v) => v.is_expired(margin_seconds),
345        }
346    }
347}
348
349/// API v3 session response
350#[derive(Debug, Clone, Deserialize, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct V3Response {
353    /// Client identifier
354    pub client_id: String,
355    /// Account identifier
356    pub account_id: String,
357    /// Timezone offset in minutes
358    pub timezone_offset: i32,
359    /// Lightstreamer WebSocket endpoint URL
360    pub lightstreamer_endpoint: String,
361    /// OAuth token information
362    pub oauth_token: OAuthToken,
363}
364
365/// OAuth token information returned by API v3
366///
367/// `Deserialize` / `Serialize` behaviour is unchanged so real IG payloads still
368/// round-trip; only the `Debug` representation is overridden to redact the
369/// `access_token` and `refresh_token`.
370#[derive(serde::Deserialize, serde::Serialize, Clone)]
371pub struct OAuthToken {
372    /// OAuth access token
373    pub access_token: String,
374    /// OAuth refresh token
375    pub refresh_token: String,
376    /// Token scope
377    pub scope: String,
378    /// Token type (typically "Bearer")
379    pub token_type: String,
380    /// Token expiry time in seconds
381    pub expires_in: String,
382    /// Timestamp when this token was created (for expiry calculation)
383    #[serde(skip, default = "chrono::Utc::now")]
384    pub created_at: chrono::DateTime<Utc>,
385}
386
387// Manual redacting `Debug` — `access_token` / `refresh_token` are credentials
388// and must never appear in logs, panics or `Debug` output.
389impl fmt::Debug for OAuthToken {
390    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391        f.debug_struct("OAuthToken")
392            .field("access_token", &"<redacted>")
393            .field("refresh_token", &"<redacted>")
394            .field("scope", &self.scope)
395            .field("token_type", &self.token_type)
396            .field("expires_in", &self.expires_in)
397            .field("created_at", &self.created_at)
398            .finish()
399    }
400}
401
402impl OAuthToken {
403    /// Parses the IG `expires_in` field (seconds, delivered as a JSON string)
404    /// into an integer count of seconds.
405    ///
406    /// On a malformed value this falls back to `0`, which makes the token read
407    /// as already expired and forces a refresh — an observable, safe degradation
408    /// rather than a silent one. Because this is called on every expiry check, a
409    /// `WARN` (with the offending value, never the token itself) is emitted at
410    /// most **once per process** to surface the problem without spamming logs.
411    #[must_use]
412    #[inline]
413    fn expires_in_secs(&self) -> i64 {
414        self.expires_in.parse::<i64>().unwrap_or_else(|_| {
415            static MALFORMED_EXPIRES_IN_WARNED: std::sync::Once = std::sync::Once::new();
416            MALFORMED_EXPIRES_IN_WARNED.call_once(|| {
417                warn!(
418                    expires_in = %self.expires_in,
419                    "malformed expires_in; treating token as expired (further occurrences suppressed)"
420                );
421            });
422            0
423        })
424    }
425
426    /// Checks if the OAuth token is expired or will expire soon
427    ///
428    /// # Arguments
429    /// * `margin_seconds` - Safety margin in seconds before actual expiry
430    ///
431    /// # Returns
432    /// `true` if the token is expired or will expire within the margin, `false` otherwise
433    #[must_use]
434    #[inline]
435    pub fn is_expired(&self, margin_seconds: u64) -> bool {
436        let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
437        is_past_effective_expiry(self.created_at, self.expires_in_secs(), margin, Utc::now())
438    }
439
440    /// Returns the Unix timestamp when the token expires (considering the margin)
441    ///
442    /// # Arguments
443    /// * `margin_seconds` - Safety margin in seconds before actual expiry
444    ///
445    /// # Returns
446    /// Unix timestamp (seconds since epoch) when the token should be considered
447    /// expired. Saturates to `0` (already expired) if the computed expiry is
448    /// before the Unix epoch or the arithmetic overflows.
449    #[must_use]
450    pub fn expire_at(&self, margin_seconds: i64) -> u64 {
451        let effective = chrono::Duration::try_seconds(self.expires_in_secs())
452            .and_then(|lifetime| self.created_at.checked_add_signed(lifetime))
453            .and_then(|expiry| {
454                chrono::Duration::try_seconds(margin_seconds)
455                    .and_then(|m| expiry.checked_sub_signed(m))
456            });
457
458        match effective {
459            Some(effective_expiry) => u64::try_from(effective_expiry.timestamp()).unwrap_or(0),
460            None => 0,
461        }
462    }
463}
464
465/// API v2 session response
466#[derive(Debug, Clone, Deserialize, Serialize)]
467#[serde(rename_all = "camelCase")]
468pub struct V2Response {
469    /// Account type (e.g., "CFD", "SPREADBET")
470    pub account_type: String,
471    /// Account information
472    pub account_info: AccountInfo,
473    /// Currency ISO code (e.g., "GBP", "USD")
474    pub currency_iso_code: String,
475    /// Currency symbol (e.g., "£", "$")
476    pub currency_symbol: String,
477    /// Current active account ID
478    pub current_account_id: String,
479    /// Lightstreamer WebSocket endpoint URL
480    pub lightstreamer_endpoint: String,
481    /// List of all accounts owned by the user
482    pub accounts: Vec<Account>,
483    /// Client identifier
484    pub client_id: String,
485    /// Timezone offset in minutes
486    pub timezone_offset: i32,
487    /// Whether user has active demo accounts
488    pub has_active_demo_accounts: bool,
489    /// Whether user has active live accounts
490    pub has_active_live_accounts: bool,
491    /// Whether trailing stops are enabled
492    pub trailing_stops_enabled: bool,
493    /// Rerouting environment if applicable
494    pub rerouting_environment: Option<String>,
495    /// Whether dealing is enabled
496    pub dealing_enabled: bool,
497    /// Security headers (CST and X-SECURITY-TOKEN)
498    #[serde(skip)]
499    pub security_headers: Option<SecurityHeaders>,
500    /// Token expiry time in seconds
501    #[serde(skip)]
502    pub expires_in: Option<u64>,
503    /// Timestamp when this token was created (for expiry calculation)
504    #[serde(skip, default = "chrono::Utc::now")]
505    pub created_at: chrono::DateTime<Utc>,
506}
507
508impl V2Response {
509    /// Sets the security headers for this session
510    ///
511    /// # Arguments
512    /// * `headers` - Security headers containing CST and X-SECURITY-TOKEN
513    pub fn set_security_headers(&mut self, headers: &SecurityHeaders) {
514        self.security_headers = Some(headers.clone());
515    }
516
517    /// Checks if the session is expired
518    ///
519    /// # Arguments
520    /// * `margin_seconds` - Safety margin in seconds before actual expiration
521    ///
522    /// # Returns
523    /// `true` if the session is expired or `expires_in` was never set
524    #[must_use]
525    pub fn is_expired(&self, margin_seconds: u64) -> bool {
526        match self.expires_in {
527            Some(expires_in) => {
528                let lifetime = i64::try_from(expires_in).unwrap_or(i64::MAX);
529                let margin = i64::try_from(margin_seconds).unwrap_or(i64::MAX);
530                is_past_effective_expiry(self.created_at, lifetime, margin, Utc::now())
531            }
532            // If expires_in was never set, treat as expired for safety
533            None => true,
534        }
535    }
536}
537
538/// Security headers for API v2 authentication
539#[derive(Clone, Deserialize, Serialize)]
540pub struct SecurityHeaders {
541    /// Client Session Token
542    pub cst: String,
543    /// Security token for request authentication
544    pub x_security_token: String,
545    /// API key for the application
546    pub x_ig_api_key: String,
547}
548
549// Manual redacting `Debug` — every field here (CST, X-SECURITY-TOKEN and the
550// API key) is a credential, so all three are masked.
551impl fmt::Debug for SecurityHeaders {
552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553        f.debug_struct("SecurityHeaders")
554            .field("cst", &"<redacted>")
555            .field("x_security_token", &"<redacted>")
556            .field("x_ig_api_key", &"<redacted>")
557            .finish()
558    }
559}
560
561/// Account balance information
562#[derive(Debug, Clone, Deserialize, Serialize)]
563#[serde(rename_all = "camelCase")]
564pub struct AccountInfo {
565    /// Total account balance
566    pub balance: f64,
567    /// Amount deposited
568    pub deposit: f64,
569    /// Current profit or loss
570    pub profit_loss: f64,
571    /// Available funds for trading
572    pub available: f64,
573}
574
575/// Trading account information
576#[derive(Debug, Clone, Deserialize, Serialize)]
577#[serde(rename_all = "camelCase")]
578pub struct Account {
579    /// Unique account identifier
580    pub account_id: String,
581    /// Human-readable account name
582    pub account_name: String,
583    /// Whether this is the preferred/default account
584    pub preferred: bool,
585    /// Account type (e.g., "CFD", "SPREADBET")
586    pub account_type: String,
587}
588
589#[cfg(test)]
590mod redaction_tests {
591    use super::*;
592
593    #[test]
594    fn test_oauth_token_debug_redacts_tokens() {
595        let token = OAuthToken {
596            access_token: "SECRET-ACCESS-VALUE".to_string(),
597            refresh_token: "SECRET-REFRESH-VALUE".to_string(),
598            scope: "read write".to_string(),
599            token_type: "Bearer".to_string(),
600            expires_in: "60".to_string(),
601            created_at: Utc::now(),
602        };
603        let rendered = format!("{token:?}");
604
605        assert!(!rendered.contains("SECRET-ACCESS-VALUE"));
606        assert!(!rendered.contains("SECRET-REFRESH-VALUE"));
607        assert!(rendered.contains("<redacted>"));
608        // Non-secret fields stay visible.
609        assert!(rendered.contains("Bearer"));
610        assert!(rendered.contains("read write"));
611    }
612
613    #[test]
614    fn test_security_headers_debug_redacts_tokens() {
615        let headers = SecurityHeaders {
616            cst: "SECRET-CST-VALUE".to_string(),
617            x_security_token: "SECRET-XST-VALUE".to_string(),
618            x_ig_api_key: "SECRET-API-KEY-VALUE".to_string(),
619        };
620        let rendered = format!("{headers:?}");
621
622        assert!(!rendered.contains("SECRET-CST-VALUE"));
623        assert!(!rendered.contains("SECRET-XST-VALUE"));
624        assert!(!rendered.contains("SECRET-API-KEY-VALUE"));
625        assert!(rendered.contains("<redacted>"));
626    }
627}
628
629#[cfg(test)]
630mod expiry_tests {
631    use super::*;
632
633    fn oauth_token(expires_in: &str) -> OAuthToken {
634        OAuthToken {
635            access_token: "ACCESS".to_string(),
636            refresh_token: "REFRESH".to_string(),
637            scope: "read write".to_string(),
638            token_type: "Bearer".to_string(),
639            expires_in: expires_in.to_string(),
640            created_at: Utc::now(),
641        }
642    }
643
644    #[test]
645    fn test_oauth_token_is_expired_malformed_expires_in_treated_expired() {
646        // A non-numeric `expires_in` must not panic; it falls back to 0 seconds
647        // of lifetime, so the token reads as already expired.
648        let token = oauth_token("not-a-number");
649        assert!(token.is_expired(60));
650    }
651
652    #[test]
653    fn test_oauth_token_is_expired_fresh_token_not_expired() {
654        // A freshly created 3600s token is well within its lifetime.
655        let token = oauth_token("3600");
656        assert!(!token.is_expired(60));
657    }
658
659    #[test]
660    fn test_oauth_token_expire_at_malformed_expires_in_saturates() {
661        // Malformed lifetime -> effective expiry at (created_at - margin), which
662        // is in the past; expire_at must not underflow into a huge u64.
663        let token = oauth_token("garbage");
664        let now = Utc::now().timestamp();
665        let expires_at = token.expire_at(1);
666        // Already-expired: the effective expiry is at or before "now".
667        assert!(expires_at <= u64::try_from(now).unwrap_or(0));
668    }
669
670    fn v2_response(expires_in: Option<u64>) -> V2Response {
671        V2Response {
672            account_type: "CFD".to_string(),
673            account_info: AccountInfo {
674                balance: 0.0,
675                deposit: 0.0,
676                profit_loss: 0.0,
677                available: 0.0,
678            },
679            currency_iso_code: "EUR".to_string(),
680            currency_symbol: "E".to_string(),
681            current_account_id: "ACC123".to_string(),
682            lightstreamer_endpoint: "https://demo-apd.marketdatasystems.com".to_string(),
683            accounts: Vec::new(),
684            client_id: "CLIENT1".to_string(),
685            timezone_offset: 1,
686            has_active_demo_accounts: true,
687            has_active_live_accounts: false,
688            trailing_stops_enabled: false,
689            rerouting_environment: None,
690            dealing_enabled: true,
691            security_headers: None,
692            expires_in,
693            created_at: Utc::now(),
694        }
695    }
696
697    #[test]
698    fn test_v2_session_expires_at_derived_from_created_at_plus_lifetime() {
699        // `expires_in` unset -> the full v2 lifetime is used, and expiry is
700        // derived from `created_at`, not the call-time clock.
701        let resp = v2_response(None);
702        let created = u64::try_from(resp.created_at.timestamp()).unwrap_or(0);
703        let session = SessionResponse::V2(resp).get_session();
704        assert_eq!(session.expires_at, created + V2_SESSION_LIFETIME_SECS);
705    }
706
707    #[test]
708    fn test_v2_response_is_expired_none_expires_in_treated_expired() {
709        // No `expires_in` recorded -> fail safe as expired.
710        let resp = v2_response(None);
711        assert!(resp.is_expired(300));
712    }
713
714    #[test]
715    fn test_v2_response_is_expired_fresh_lifetime_not_expired() {
716        let resp = v2_response(Some(V2_SESSION_LIFETIME_SECS));
717        assert!(!resp.is_expired(300));
718    }
719}
720
721#[cfg(test)]
722mod session_response_round_trip_tests {
723    use super::*;
724
725    // Captured demo-account `/session` v2 response body. Account and client
726    // identifiers are opaque IG references, not credentials; the CST /
727    // X-SECURITY-TOKEN secrets arrive in headers (see `login_v2`), never in this
728    // body, so nothing sensitive is embedded here.
729    const V2_DEMO_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}"#;
730
731    // Second captured v2 body with a distinct `currentAccountId`, pinning that
732    // the untagged enum resolves to `V2` and the field is read back correctly.
733    const V2_ALT_BODY: &str = r#"{"accountType":"CFD","accountInfo":{"balance":18791.56,"deposit":3300.18,"profitLoss":187.42,"available":14952.68},"currencyIsoCode":"EUR","currencySymbol":"E","currentAccountId":"BS0Y3","lightstreamerEndpoint":"https://apd.marketdatasystems.com","accounts":[{"accountId":"BS0Y3","accountName":"Opciones Prod","preferred":true,"accountType":"CFD"},{"accountId":"BSI1I","accountName":"Barreras y Opciones","preferred":false,"accountType":"CFD"},{"accountId":"BSU96","accountName":"Turbos","preferred":false,"accountType":"PHYSICAL"},{"accountId":"BTCKN","accountName":"CFD","preferred":false,"accountType":"CFD"},{"accountId":"BXNIZ","accountName":"Principal","preferred":false,"accountType":"CFD"}],"clientId":"102828353","timezoneOffset":1,"hasActiveDemoAccounts":true,"hasActiveLiveAccounts":true,"trailingStopsEnabled":false,"reroutingEnvironment":null,"dealingEnabled":true}"#;
734
735    // Captured v3 `/session` response shape (IG docs example). The `oauthToken`
736    // access/refresh values here are placeholders, never real secrets — the
737    // round-trip only needs the payload *shape*, and rule 7 forbids embedding
738    // real tokens in fixtures.
739    const V3_BODY: &str = r#"{"clientId":"101290216","accountId":"ZZZZZ","timezoneOffset":1,"lightstreamerEndpoint":"https://demo-apd.marketdatasystems.com","oauthToken":{"access_token":"placeholder-access","refresh_token":"placeholder-refresh","scope":"profile","token_type":"Bearer","expires_in":"60"}}"#;
740
741    #[test]
742    fn test_session_response_v2_demo_body_parses_as_v2() {
743        let response: SessionResponse =
744            serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
745        assert!(response.is_v2());
746        assert!(!response.is_v3());
747    }
748
749    #[test]
750    fn test_v2_response_demo_body_fields_round_trip() {
751        let response: V2Response =
752            serde_json::from_str(V2_DEMO_BODY).expect("v2 demo body should deserialize");
753        assert_eq!(response.account_type, "CFD");
754        assert_eq!(response.current_account_id, "ZZZZZ");
755        assert_eq!(response.accounts.len(), 3);
756        assert_eq!(response.currency_iso_code, "EUR");
757    }
758
759    #[test]
760    fn test_v2_response_alt_body_reads_current_account_id() {
761        let response: SessionResponse =
762            serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
763        assert!(response.is_v2());
764
765        let response: V2Response =
766            serde_json::from_str(V2_ALT_BODY).expect("v2 alt body should deserialize");
767        assert_eq!(response.account_type, "CFD");
768        assert_eq!(response.current_account_id, "BS0Y3");
769        assert_eq!(response.accounts.len(), 5);
770    }
771
772    #[test]
773    fn test_session_response_v3_body_parses_as_v3() {
774        let response: SessionResponse =
775            serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
776        assert!(response.is_v3());
777        assert!(!response.is_v2());
778    }
779
780    #[test]
781    fn test_v3_response_body_fields_round_trip() {
782        let response: V3Response =
783            serde_json::from_str(V3_BODY).expect("v3 body should deserialize");
784        assert_eq!(response.client_id, "101290216");
785        assert_eq!(response.account_id, "ZZZZZ");
786        assert_eq!(response.timezone_offset, 1);
787        assert_eq!(response.oauth_token.token_type, "Bearer");
788        assert_eq!(response.oauth_token.expires_in, "60");
789        // The freshly created token is not yet within its 10s expiry margin.
790        assert!(!response.oauth_token.is_expired(10));
791    }
792}