From 96fb0f1c652866a58de345647d5a99d9a9987fdb Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Tue, 4 Nov 2025 17:02:19 +0100 Subject: [PATCH 1/6] Cache JWT skew --- .../crates/nym-vpn-api-client/src/client.rs | 130 +++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index 4d8fd4bb37..5fd169e209 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -1,7 +1,10 @@ // Copyright 2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only -use std::time::Duration; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use backon::Retryable; use nym_credential_proxy_requests::api::v1::ticketbook::models::PartialVerificationKeysResponse; @@ -9,7 +12,8 @@ use nym_http_api_client::{ ApiClient, Client, HttpClientError, NO_PARAMS, Params, PathSegments, Url, UserAgent, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use time::OffsetDateTime; +use time::{Duration as TimeDuration, OffsetDateTime}; +use tokio::sync::RwLock; use crate::{ ResolverOverrides, api_urls_to_urls, @@ -40,11 +44,42 @@ pub(crate) const DEVICE_AUTHORIZATION_HEADER: &str = "x-device-authorization"; // GET requests can unfortunately take a long time over the mixnet pub(crate) const NYM_VPN_API_TIMEOUT: Duration = Duration::from_secs(60); +const SKEW_CACHE_TTL: Duration = Duration::from_secs(4 * 60 * 60); // 4 hours + +#[derive(Default, Debug)] +struct SkewState { + skew: Option, + expires_at: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SkewStatus { + Missing, + Expired(TimeDuration), + Valid(TimeDuration), +} + +impl SkewState { + fn update(&mut self, skew: TimeDuration, now: Instant) { + self.skew = Some(skew); + self.expires_at = Some(now + SKEW_CACHE_TTL); + } + + fn status(&self, now: Instant) -> SkewStatus { + match (self.skew, self.expires_at) { + (Some(skew), Some(expires_at)) if expires_at > now => SkewStatus::Valid(skew), + (Some(skew), Some(_)) => SkewStatus::Expired(skew), + _ => SkewStatus::Missing, + } + } +} + #[derive(Clone, Debug)] pub struct VpnApiClient { inner: Client, urls: Vec, user_agent: UserAgent, + skew_state: Arc>, } impl VpnApiClient { @@ -65,6 +100,7 @@ impl VpnApiClient { inner, urls, user_agent, + skew_state: Arc::new(RwLock::new(SkewState::default())), }) } @@ -96,6 +132,7 @@ impl VpnApiClient { inner, urls, user_agent, + skew_state: Arc::new(RwLock::new(SkewState::default())), }) } @@ -153,8 +190,66 @@ impl VpnApiClient { } } - async fn sync_with_remote_time(&self) -> Result> { + async fn refresh_skew(&self) -> Result { let remote_time = self.get_remote_time().await?; + let skew = remote_time.local_time_ahead_skew(); + let now = Instant::now(); + + { + let mut state = self.skew_state.write().await; + state.update(skew, now); + } + + tracing::debug!(skew = ?skew, "Refreshed VPN API time skew"); + + Ok(remote_time) + } + + async fn current_remote_time(&self) -> Result> { + let now = Instant::now(); + let status = { + let state = self.skew_state.read().await; + state.status(now) + }; + + match status { + SkewStatus::Valid(skew) => { + let local_time = OffsetDateTime::now_utc(); + let estimated_remote_time = local_time - skew; + let cached_remote_time = VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); + + if Self::use_remote_time(cached_remote_time) { + Ok(Some(cached_remote_time)) + } else { + Ok(None) + } + } + SkewStatus::Expired(skew) => { + tracing::debug!( + skew = ?skew, + "Cached VPN API time skew expired, refreshing" + ); + let refreshed_time = self.refresh_skew().await?; + if Self::use_remote_time(refreshed_time) { + Ok(Some(refreshed_time)) + } else { + Ok(None) + } + } + SkewStatus::Missing => { + tracing::debug!("No cached VPN API time skew present, refreshing"); + let refreshed_time = self.refresh_skew().await?; + if Self::use_remote_time(refreshed_time) { + Ok(Some(refreshed_time)) + } else { + Ok(None) + } + } + } + } + + async fn sync_with_remote_time(&self) -> Result> { + let remote_time = self.refresh_skew().await?; if Self::use_remote_time(remote_time) { Ok(Some(remote_time)) @@ -198,7 +293,21 @@ impl VpnApiClient { where T: DeserializeOwned, { - match self.get_query::(path, account, device, None).await { + let jwt = match self.current_remote_time().await { + Ok(remote_time) => remote_time, + Err(err) => { + tracing::debug!( + error = %err, + "Failed to determine cached remote time" + ); + None + } + }; + + match self + .get_query::(path, account, device, jwt) + .await + { Ok(response) => Ok(response), Err(err) => { if let HttpClientError::EndpointFailure { error, .. } = &err @@ -364,8 +473,19 @@ impl VpnApiClient { T: DeserializeOwned, B: Serialize, { + let jwt = match self.current_remote_time().await { + Ok(remote_time) => remote_time, + Err(err) => { + tracing::debug!( + error = %err, + "Failed to determine cached remote time" + ); + None + } + }; + match self - .post_query::(path, json_body, account, device, None) + .post_query::(path, json_body, account, device, jwt) .await { Ok(response) => Ok(response), From 843fdcc396d9175a89b21af3753600dfb0e087c5 Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Wed, 5 Nov 2025 13:36:04 +0100 Subject: [PATCH 2/6] Run cargo fmt --- nym-vpn-core/crates/nym-vpn-api-client/src/client.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index 5fd169e209..ed152c1b2e 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -216,7 +216,8 @@ impl VpnApiClient { SkewStatus::Valid(skew) => { let local_time = OffsetDateTime::now_utc(); let estimated_remote_time = local_time - skew; - let cached_remote_time = VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); + let cached_remote_time = + VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); if Self::use_remote_time(cached_remote_time) { Ok(Some(cached_remote_time)) @@ -304,10 +305,7 @@ impl VpnApiClient { } }; - match self - .get_query::(path, account, device, jwt) - .await - { + match self.get_query::(path, account, device, jwt).await { Ok(response) => Ok(response), Err(err) => { if let HttpClientError::EndpointFailure { error, .. } = &err From 4e31d422a4d8bc6bca0c761e1661a07a68ce0f53 Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Wed, 5 Nov 2025 14:55:45 +0100 Subject: [PATCH 3/6] Make VpiApiClient skew_state Optional --- .../crates/nym-vpn-api-client/src/client.rs | 96 ++++++++++--------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index ed152c1b2e..82e7bd1219 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -46,30 +46,36 @@ pub(crate) const NYM_VPN_API_TIMEOUT: Duration = Duration::from_secs(60); const SKEW_CACHE_TTL: Duration = Duration::from_secs(4 * 60 * 60); // 4 hours -#[derive(Default, Debug)] +#[derive(Debug)] struct SkewState { - skew: Option, - expires_at: Option, + skew: TimeDuration, + expires_at: Instant, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SkewStatus { - Missing, Expired(TimeDuration), Valid(TimeDuration), } impl SkewState { + fn new(skew: TimeDuration, now: Instant) -> Self { + Self { + skew, + expires_at: now + SKEW_CACHE_TTL, + } + } + fn update(&mut self, skew: TimeDuration, now: Instant) { - self.skew = Some(skew); - self.expires_at = Some(now + SKEW_CACHE_TTL); + self.skew = skew; + self.expires_at = now + SKEW_CACHE_TTL; } fn status(&self, now: Instant) -> SkewStatus { - match (self.skew, self.expires_at) { - (Some(skew), Some(expires_at)) if expires_at > now => SkewStatus::Valid(skew), - (Some(skew), Some(_)) => SkewStatus::Expired(skew), - _ => SkewStatus::Missing, + if self.expires_at > now { + SkewStatus::Valid(self.skew) + } else { + SkewStatus::Expired(self.skew) } } } @@ -79,7 +85,7 @@ pub struct VpnApiClient { inner: Client, urls: Vec, user_agent: UserAgent, - skew_state: Arc>, + skew_state: Arc>>, } impl VpnApiClient { @@ -100,7 +106,7 @@ impl VpnApiClient { inner, urls, user_agent, - skew_state: Arc::new(RwLock::new(SkewState::default())), + skew_state: Arc::new(RwLock::new(None)), }) } @@ -132,7 +138,7 @@ impl VpnApiClient { inner, urls, user_agent, - skew_state: Arc::new(RwLock::new(SkewState::default())), + skew_state: Arc::new(RwLock::new(None)), }) } @@ -196,8 +202,11 @@ impl VpnApiClient { let now = Instant::now(); { - let mut state = self.skew_state.write().await; - state.update(skew, now); + let mut skew_state = self.skew_state.write().await; + match skew_state.as_mut() { + Some(existing) => existing.update(skew, now), + None => *skew_state = Some(SkewState::new(skew, now)), + } } tracing::debug!(skew = ?skew, "Refreshed VPN API time skew"); @@ -206,38 +215,35 @@ impl VpnApiClient { } async fn current_remote_time(&self) -> Result> { - let now = Instant::now(); - let status = { - let state = self.skew_state.read().await; - state.status(now) - }; - - match status { - SkewStatus::Valid(skew) => { - let local_time = OffsetDateTime::now_utc(); - let estimated_remote_time = local_time - skew; - let cached_remote_time = - VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); - - if Self::use_remote_time(cached_remote_time) { - Ok(Some(cached_remote_time)) - } else { - Ok(None) + match &*self.skew_state.read().await { + Some(skew_state) => match skew_state.status(Instant::now()) { + SkewStatus::Valid(skew) => { + tracing::debug!("Using cached VPN API time skew"); + let local_time = OffsetDateTime::now_utc(); + let estimated_remote_time = local_time - skew; + let cached_remote_time = + VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); + + if Self::use_remote_time(cached_remote_time) { + Ok(Some(cached_remote_time)) + } else { + Ok(None) + } } - } - SkewStatus::Expired(skew) => { - tracing::debug!( - skew = ?skew, - "Cached VPN API time skew expired, refreshing" - ); - let refreshed_time = self.refresh_skew().await?; - if Self::use_remote_time(refreshed_time) { - Ok(Some(refreshed_time)) - } else { - Ok(None) + SkewStatus::Expired(skew) => { + tracing::debug!( + skew = ?skew, + "Cached VPN API time skew expired, refreshing" + ); + let refreshed_time = self.refresh_skew().await?; + if Self::use_remote_time(refreshed_time) { + Ok(Some(refreshed_time)) + } else { + Ok(None) + } } - } - SkewStatus::Missing => { + }, + None => { tracing::debug!("No cached VPN API time skew present, refreshing"); let refreshed_time = self.refresh_skew().await?; if Self::use_remote_time(refreshed_time) { From f2479141d9bf51db0152cff1b32daa950fc9e6f2 Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Wed, 5 Nov 2025 18:16:34 +0100 Subject: [PATCH 4/6] Fix deadlock --- .../crates/nym-vpn-api-client/src/client.rs | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index 82e7bd1219..3275fe3b21 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -204,7 +204,7 @@ impl VpnApiClient { { let mut skew_state = self.skew_state.write().await; match skew_state.as_mut() { - Some(existing) => existing.update(skew, now), + Some(state) => state.update(skew, now), None => *skew_state = Some(SkewState::new(skew, now)), } } @@ -215,34 +215,38 @@ impl VpnApiClient { } async fn current_remote_time(&self) -> Result> { - match &*self.skew_state.read().await { - Some(skew_state) => match skew_state.status(Instant::now()) { - SkewStatus::Valid(skew) => { - tracing::debug!("Using cached VPN API time skew"); - let local_time = OffsetDateTime::now_utc(); - let estimated_remote_time = local_time - skew; - let cached_remote_time = - VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); - - if Self::use_remote_time(cached_remote_time) { - Ok(Some(cached_remote_time)) - } else { - Ok(None) - } + let now = Instant::now(); + let status = { + let state = self.skew_state.read().await; + state.as_ref().map(|state| state.status(now)) + }; + + match status { + Some(SkewStatus::Valid(skew)) => { + tracing::debug!("Valid VPN API time skew"); + let local_time = OffsetDateTime::now_utc(); + let estimated_remote_time = local_time - skew; + let cached_remote_time = + VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); + + if Self::use_remote_time(cached_remote_time) { + Ok(Some(cached_remote_time)) + } else { + Ok(None) } - SkewStatus::Expired(skew) => { - tracing::debug!( - skew = ?skew, - "Cached VPN API time skew expired, refreshing" - ); - let refreshed_time = self.refresh_skew().await?; - if Self::use_remote_time(refreshed_time) { - Ok(Some(refreshed_time)) - } else { - Ok(None) - } + } + Some(SkewStatus::Expired(skew)) => { + tracing::debug!( + skew = ?skew, + "Expired VPN API time skew, refreshing" + ); + let refreshed_time = self.refresh_skew().await?; + if Self::use_remote_time(refreshed_time) { + Ok(Some(refreshed_time)) + } else { + Ok(None) } - }, + } None => { tracing::debug!("No cached VPN API time skew present, refreshing"); let refreshed_time = self.refresh_skew().await?; From 7cdc88826ac4857ee9cd9276936c360b466284ab Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Thu, 6 Nov 2025 16:58:32 +0100 Subject: [PATCH 5/6] Simplify current_remote_time & SkewStatus enum --- .../crates/nym-vpn-api-client/src/client.rs | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index 3275fe3b21..24d59eb9bf 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -54,7 +54,7 @@ struct SkewState { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SkewStatus { - Expired(TimeDuration), + Expired(), Valid(TimeDuration), } @@ -75,7 +75,7 @@ impl SkewState { if self.expires_at > now { SkewStatus::Valid(self.skew) } else { - SkewStatus::Expired(self.skew) + SkewStatus::Expired() } } } @@ -221,42 +221,26 @@ impl VpnApiClient { state.as_ref().map(|state| state.status(now)) }; - match status { + let cached_remote_time = match status { Some(SkewStatus::Valid(skew)) => { tracing::debug!("Valid VPN API time skew"); let local_time = OffsetDateTime::now_utc(); let estimated_remote_time = local_time - skew; - let cached_remote_time = - VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time); - if Self::use_remote_time(cached_remote_time) { - Ok(Some(cached_remote_time)) - } else { - Ok(None) - } - } - Some(SkewStatus::Expired(skew)) => { - tracing::debug!( - skew = ?skew, - "Expired VPN API time skew, refreshing" - ); - let refreshed_time = self.refresh_skew().await?; - if Self::use_remote_time(refreshed_time) { - Ok(Some(refreshed_time)) - } else { - Ok(None) - } + VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time) } - None => { - tracing::debug!("No cached VPN API time skew present, refreshing"); - let refreshed_time = self.refresh_skew().await?; - if Self::use_remote_time(refreshed_time) { - Ok(Some(refreshed_time)) - } else { - Ok(None) - } + Some(SkewStatus::Expired()) | None => { + tracing::debug!("VPN API time skew expired or not present, refreshing"); + + self.refresh_skew().await? } - } + }; + + Ok(if Self::use_remote_time(cached_remote_time) { + Some(cached_remote_time) + } else { + None + }) } async fn sync_with_remote_time(&self) -> Result> { From cebf4dcaba66f8ff94c6774f06cc5471c36bb2ac Mon Sep 17 00:00:00 2001 From: agentpietrucha Date: Mon, 10 Nov 2025 16:13:26 +0100 Subject: [PATCH 6/6] Add unit tests for JWT skew cache --- .../crates/nym-vpn-api-client/src/client.rs | 179 +++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs index 24d59eb9bf..9014eaf7d9 100644 --- a/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs +++ b/nym-vpn-core/crates/nym-vpn-api-client/src/client.rs @@ -54,7 +54,7 @@ struct SkewState { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SkewStatus { - Expired(), + Expired, Valid(TimeDuration), } @@ -75,7 +75,7 @@ impl SkewState { if self.expires_at > now { SkewStatus::Valid(self.skew) } else { - SkewStatus::Expired() + SkewStatus::Expired } } } @@ -86,6 +86,8 @@ pub struct VpnApiClient { urls: Vec, user_agent: UserAgent, skew_state: Arc>>, + #[cfg(test)] + mock_remote_time: Arc>>, } impl VpnApiClient { @@ -107,6 +109,8 @@ impl VpnApiClient { urls, user_agent, skew_state: Arc::new(RwLock::new(None)), + #[cfg(test)] + mock_remote_time: Arc::new(RwLock::new(None)), }) } @@ -139,6 +143,8 @@ impl VpnApiClient { urls, user_agent, skew_state: Arc::new(RwLock::new(None)), + #[cfg(test)] + mock_remote_time: Arc::new(RwLock::new(None)), }) } @@ -166,6 +172,11 @@ impl VpnApiClient { } pub async fn get_remote_time(&self) -> Result { + #[cfg(test)] + if let Some(mocked) = self.mock_remote_time.read().await.clone() { + return Ok(mocked); + } + let time_before = OffsetDateTime::now_utc(); let remote_timestamp = self.get_health().await?.timestamp_utc; let time_after = OffsetDateTime::now_utc(); @@ -229,7 +240,7 @@ impl VpnApiClient { VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time) } - Some(SkewStatus::Expired()) | None => { + Some(SkewStatus::Expired) | None => { tracing::debug!("VPN API time skew expired or not present, refreshing"); self.refresh_skew().await? @@ -1393,8 +1404,170 @@ impl VpnApiClient { .map_err(Box::new) .map_err(VpnApiClientError::GetVpnNetworkDetails) } + + // TEST HELPERS + #[cfg(test)] + pub(super) async fn set_mock_remote_time(&self, remote_time: Option) { + let mut guard = self.mock_remote_time.write().await; + *guard = remote_time; + } } fn jwt_error(error: &str) -> bool { error.to_lowercase().contains("jwt") } + +// skew_state tests +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::{Duration as StdDuration, Instant}; + + use super::*; + + fn test_user_agent() -> UserAgent { + UserAgent { + application: "vpn-api-client-test".to_string(), + version: "0.0.1".to_string(), + platform: "test-platform".to_string(), + git_commit: "test-commit".to_string(), + } + } + + fn test_client() -> VpnApiClient { + let base_url = "http://localhost"; + let inner = Client::new_url(base_url, Some(StdDuration::from_secs(1))).unwrap(); + let parsed_url = Url::parse(base_url).unwrap(); + + VpnApiClient { + inner, + urls: vec![parsed_url], + user_agent: test_user_agent(), + skew_state: Arc::new(RwLock::new(None)), + mock_remote_time: Arc::new(RwLock::new(None)), + } + } + + fn remote_time_with_skew(seconds: i64) -> VpnApiTime { + let local_time = OffsetDateTime::now_utc(); + let estimated_remote_time = local_time - TimeDuration::seconds(seconds); + VpnApiTime::from_estimated_remote_time(local_time, estimated_remote_time) + } + + #[tokio::test] + async fn current_remote_time_returns_cached_for_valid_skew() { + let client = test_client(); + + { + let mut state = client.skew_state.write().await; + *state = Some(SkewState { + skew: TimeDuration::seconds(120), + expires_at: Instant::now() + StdDuration::from_secs(60), + }); + } + + { + let state = client.skew_state.read().await; + assert!(matches!( + state.as_ref().unwrap().status(Instant::now()), + SkewStatus::Valid(_) + )); + } + + let remote_time = client.current_remote_time().await.unwrap(); + assert!(remote_time.is_some()); + + let state = client.skew_state.read().await; + assert!(matches!( + state.as_ref().unwrap().status(Instant::now()), + SkewStatus::Valid(_) + )); + } + + #[tokio::test] + async fn current_remote_time_returns_none_for_synced_skew() { + let client = test_client(); + + { + let mut state = client.skew_state.write().await; + *state = Some(SkewState { + skew: TimeDuration::seconds(1), + expires_at: Instant::now() + StdDuration::from_secs(60), + }); + } + + { + let state = client.skew_state.read().await; + assert!(matches!( + state.as_ref().unwrap().status(Instant::now()), + SkewStatus::Valid(_) + )); + } + + let remote_time = client.current_remote_time().await.unwrap(); + assert!(remote_time.is_none()); + } + + #[tokio::test] + async fn current_remote_time_refreshes_expired_skew() { + let client = test_client(); + + { + let mut state = client.skew_state.write().await; + *state = Some(SkewState { + skew: TimeDuration::seconds(10), + expires_at: Instant::now() - StdDuration::from_secs(1), + }); + } + + let mocked_remote_time = remote_time_with_skew(180); + client.set_mock_remote_time(Some(mocked_remote_time)).await; + + { + let state = client.skew_state.read().await; + assert!(matches!( + state.as_ref().unwrap().status(Instant::now()), + SkewStatus::Expired + )); + } + + let remote_time = client.current_remote_time().await.unwrap(); + assert!(remote_time.is_some()); + + let state = client.skew_state.read().await; + assert!(matches!( + state.as_ref().unwrap().status(Instant::now()), + SkewStatus::Valid(_) + )); + assert_eq!( + remote_time + .unwrap() + .local_time_ahead_skew() + .whole_seconds() + .abs(), + 180 + ); + } + + #[tokio::test] + async fn current_remote_time_refreshes_when_missing() { + let client = test_client(); + + let mocked_remote_time = remote_time_with_skew(200); + client.set_mock_remote_time(Some(mocked_remote_time)).await; + + assert!(client.skew_state.read().await.is_none()); + let remote_time = client.current_remote_time().await.unwrap(); + assert!(remote_time.is_some()); + + assert!(client.skew_state.read().await.is_some()); + assert_eq!( + remote_time + .unwrap() + .local_time_ahead_skew() + .whole_seconds() + .abs(), + 200 + ); + } +}