1pub mod client;
19pub mod session;
20
21use std::sync::{Mutex as StdMutex, MutexGuard};
22use std::time::Duration;
23
24use anyhow::{anyhow, bail, Context, Result};
25use chrono::TimeDelta;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use tokio::task::JoinHandle;
29use tokio_util::sync::CancellationToken;
30
31use crate::utils::browser_command::split_browser_command;
32use crate::utils::env::EnvSource;
33use crate::utils::secret::Secret;
34use crate::utils::settings::Settings;
35use client::{
36 AuthMethod, BrowserConfig, BrowserLaunch, Error as ClientError, KeyPairConfig, Row,
37 SnowflakeClient, SnowflakeClientConfig, SnowflakeSession,
38};
39use session::{PoolRegistry, QueryContext, SessionInfo, SessionKey, SessionPool};
40
41const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
43const ENV_USER: &str = "SNOWFLAKE_USER";
45const ENV_HOST: &str = "SNOWFLAKE_HOST";
49const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
51const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
53const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
55const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
57const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
59const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
61const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
63const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
65const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
68const ENV_AUTHENTICATOR: &str = "SNOWFLAKE_AUTHENTICATOR";
71const ENV_TOKEN: &str = "SNOWFLAKE_TOKEN";
73const ENV_PRIVATE_KEY_PATH: &str = "SNOWFLAKE_PRIVATE_KEY_PATH";
75const ENV_PRIVATE_KEY: &str = "SNOWFLAKE_PRIVATE_KEY";
78const ENV_PRIVATE_KEY_PASSPHRASE: &str = "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE";
81const ENV_BROWSER_COMMAND: &str = "SNOWFLAKE_BROWSER_COMMAND";
90
91const DEFAULT_POOL_SIZE: usize = 4;
94const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
97const SQL_PREVIEW_MAX: usize = 60;
99const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
104const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
107
108#[derive(Clone, Debug)]
114pub struct SnowflakeEngineConfig {
115 pub default_account: Option<String>,
117 pub default_user: Option<String>,
119 pub default_host: Option<String>,
123 pub default_warehouse: Option<String>,
125 pub default_role: Option<String>,
127 pub default_database: Option<String>,
129 pub default_schema: Option<String>,
131 pub auth: AuthMethod,
135 pub pool_size: usize,
137 pub http_timeout: Duration,
139 pub auth_timeout: Duration,
142 pub query_timeout: Duration,
144 pub heartbeat_interval: Duration,
147}
148
149impl Default for SnowflakeEngineConfig {
150 fn default() -> Self {
151 Self {
152 default_account: None,
153 default_user: None,
154 default_host: None,
155 default_warehouse: None,
156 default_role: None,
157 default_database: None,
158 default_schema: None,
159 auth: AuthMethod::ExternalBrowser(BrowserConfig::default()),
160 pool_size: DEFAULT_POOL_SIZE,
161 http_timeout: client::config::DEFAULT_HTTP_TIMEOUT,
162 auth_timeout: DEFAULT_AUTH_TIMEOUT,
163 query_timeout: client::config::DEFAULT_QUERY_TIMEOUT,
164 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
165 }
166 }
167}
168
169impl SnowflakeEngineConfig {
170 pub fn from_env_and_settings() -> Result<Self> {
179 let settings = Settings::load().unwrap_or_default();
180 let pool_size = settings
181 .get_env_var(ENV_POOL_SIZE)
182 .and_then(|s| s.trim().parse::<usize>().ok())
183 .filter(|&n| n >= 1)
184 .unwrap_or(DEFAULT_POOL_SIZE);
185 let secs = |key: &str| {
186 settings
187 .get_env_var(key)
188 .and_then(|s| s.trim().parse::<u64>().ok())
189 .filter(|&n| n >= 1)
190 .map(Duration::from_secs)
191 };
192 let private_key_pem = match settings.get_env_var(ENV_PRIVATE_KEY_PATH) {
193 Some(path) => Some(
194 std::fs::read_to_string(&path)
195 .with_context(|| format!("reading {ENV_PRIVATE_KEY_PATH} '{path}'"))?,
196 ),
197 None => settings.get_env_var(ENV_PRIVATE_KEY),
198 };
199 let auth = resolve_auth_method(
200 settings.get_env_var(ENV_AUTHENTICATOR).as_deref(),
201 settings.get_env_var(ENV_BROWSER_COMMAND),
202 settings.get_env_var(ENV_TOKEN),
203 private_key_pem,
204 settings.get_env_var(ENV_PRIVATE_KEY_PASSPHRASE),
205 )?;
206 Ok(Self {
207 default_account: settings.get_env_var(ENV_ACCOUNT),
208 default_user: settings.get_env_var(ENV_USER),
209 default_host: host_override_from(settings.get_env_var(ENV_HOST)),
210 default_warehouse: settings.get_env_var(ENV_WAREHOUSE),
211 default_role: settings.get_env_var(ENV_ROLE),
212 default_database: settings.get_env_var(ENV_DATABASE),
213 default_schema: settings.get_env_var(ENV_SCHEMA),
214 auth,
215 pool_size,
216 http_timeout: secs(ENV_HTTP_TIMEOUT).unwrap_or(client::config::DEFAULT_HTTP_TIMEOUT),
217 auth_timeout: secs(ENV_AUTH_TIMEOUT).unwrap_or(DEFAULT_AUTH_TIMEOUT),
218 query_timeout: secs(ENV_QUERY_TIMEOUT).unwrap_or(client::config::DEFAULT_QUERY_TIMEOUT),
219 heartbeat_interval: heartbeat_interval_from(
220 settings.get_env_var(ENV_HEARTBEAT_INTERVAL),
221 ),
222 })
223 }
224}
225
226fn host_override_from(raw: Option<String>) -> Option<String> {
230 raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
231}
232
233fn heartbeat_interval_from(raw: Option<String>) -> Duration {
238 raw.and_then(|s| s.trim().parse::<u64>().ok())
239 .map_or(DEFAULT_HEARTBEAT_INTERVAL, Duration::from_secs)
240}
241
242fn resolve_auth_method(
260 authenticator: Option<&str>,
261 browser_command: Option<String>,
262 token: Option<String>,
263 private_key_pem: Option<String>,
264 passphrase: Option<String>,
265) -> Result<AuthMethod> {
266 let selector = authenticator.unwrap_or("").trim().to_ascii_lowercase();
267 match selector.as_str() {
268 "" | "externalbrowser" | "external_browser" => {
269 let launch = match browser_command
270 .map(|c| c.trim().to_string())
271 .filter(|c| !c.is_empty())
272 {
273 Some(cmd) => {
274 BrowserLaunch::Command(split_browser_command(ENV_BROWSER_COMMAND, &cmd)?)
275 }
276 None => BrowserLaunch::Auto,
277 };
278 Ok(AuthMethod::ExternalBrowser(BrowserConfig {
279 launch,
280 ..BrowserConfig::default()
281 }))
282 }
283 "programmatic_access_token" | "pat" => {
284 let token = token
285 .map(|t| t.trim().to_string())
286 .filter(|t| !t.is_empty())
287 .ok_or_else(|| {
288 anyhow!("{ENV_AUTHENTICATOR}={selector} requires {ENV_TOKEN} to be set")
289 })?;
290 Ok(AuthMethod::ProgrammaticAccessToken {
291 token: Secret::from(token),
292 })
293 }
294 "snowflake_jwt" | "keypair" | "key_pair" | "jwt" => {
295 if passphrase.is_some_and(|p| !p.trim().is_empty()) {
296 bail!(
297 "{ENV_PRIVATE_KEY_PASSPHRASE} is set, but encrypted private keys are not yet \
298 supported; decrypt the key with `openssl pkcs8 -in key.p8 -out \
299 key_unencrypted.p8` and unset {ENV_PRIVATE_KEY_PASSPHRASE}"
300 );
301 }
302 let private_key_pem = private_key_pem
303 .filter(|k| !k.trim().is_empty())
304 .ok_or_else(|| {
305 anyhow!(
306 "{ENV_AUTHENTICATOR}={selector} requires {ENV_PRIVATE_KEY_PATH} or \
307 {ENV_PRIVATE_KEY}"
308 )
309 })?;
310 Ok(AuthMethod::KeyPairJwt(KeyPairConfig {
311 private_key_pem: Secret::from(private_key_pem),
312 }))
313 }
314 other => Err(anyhow!(
315 "unknown {ENV_AUTHENTICATOR} '{other}' \
316 (expected externalbrowser, programmatic_access_token, or snowflake_jwt)"
317 )),
318 }
319}
320
321#[derive(Clone, Debug, Default, Deserialize, Serialize)]
329#[serde(default)]
330pub struct QueryRequest {
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub account: Option<String>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub user: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub warehouse: Option<String>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub role: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub database: Option<String>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub schema: Option<String>,
349 pub sql: String,
351}
352
353impl QueryRequest {
354 pub fn fill_defaults_from(&mut self, env: &impl EnvSource) {
363 let fill = |slot: &mut Option<String>, key: &str| {
364 if slot.is_none() {
365 *slot = env.var(key);
366 }
367 };
368 fill(&mut self.account, ENV_ACCOUNT);
369 fill(&mut self.user, ENV_USER);
370 fill(&mut self.warehouse, ENV_WAREHOUSE);
371 fill(&mut self.role, ENV_ROLE);
372 fill(&mut self.database, ENV_DATABASE);
373 fill(&mut self.schema, ENV_SCHEMA);
374 }
375 fn overrides(&self) -> QueryContext {
378 QueryContext {
379 warehouse: self.warehouse.clone(),
380 role: self.role.clone(),
381 database: self.database.clone(),
382 schema: self.schema.clone(),
383 }
384 }
385}
386
387struct HeartbeatTask {
389 token: CancellationToken,
390 handle: JoinHandle<()>,
391}
392
393pub struct SnowflakeEngine {
400 config: SnowflakeEngineConfig,
401 registry: PoolRegistry,
402 heartbeat: StdMutex<Option<HeartbeatTask>>,
403}
404
405impl SnowflakeEngine {
406 #[must_use]
408 pub fn new(config: SnowflakeEngineConfig) -> Self {
409 Self {
410 config,
411 registry: PoolRegistry::new(),
412 heartbeat: StdMutex::new(None),
413 }
414 }
415
416 pub fn start_heartbeat(&self) {
427 let interval = self.config.heartbeat_interval;
428 if interval.is_zero() {
429 return;
430 }
431 if tokio::runtime::Handle::try_current().is_err() {
432 tracing::debug!("no tokio runtime; Snowflake keep-alive heartbeat not started");
433 return;
434 }
435 let mut guard = self.lock_heartbeat();
436 if guard.is_some() {
437 return;
438 }
439 let token = CancellationToken::new();
440 let loop_token = token.clone();
441 let registry = self.registry.clone();
442 let handle = tokio::spawn(async move {
443 loop {
444 tokio::select! {
445 () = loop_token.cancelled() => break,
446 () = tokio::time::sleep(interval) => {
447 heartbeat_all_pools(®istry, interval).await;
448 }
449 }
450 }
451 });
452 *guard = Some(HeartbeatTask { token, handle });
453 }
454
455 fn lock_heartbeat(&self) -> MutexGuard<'_, Option<HeartbeatTask>> {
456 self.heartbeat
457 .lock()
458 .unwrap_or_else(std::sync::PoisonError::into_inner)
459 }
460
461 #[must_use]
463 pub fn sessions(&self) -> Vec<SessionInfo> {
464 self.registry.snapshot()
465 }
466
467 #[must_use]
469 pub fn pool_count(&self) -> usize {
470 self.registry.len()
471 }
472
473 pub fn disconnect(&self, account: &str, user: &str) -> bool {
475 let key = SessionKey::new(normalize_account(account), user.trim());
476 self.registry.remove(&key).is_some()
477 }
478
479 pub fn disconnect_by_id(&self, id: u64) -> bool {
481 self.registry.remove_by_id(id).is_some()
482 }
483
484 pub fn disconnect_all(&self) -> usize {
486 self.registry.take_all().len()
487 }
488
489 pub async fn cancel(&self, account: &str, user: &str, member: Option<u64>) -> usize {
497 let key = SessionKey::new(normalize_account(account), user.trim());
498 match self.registry.get(&key) {
499 Some(pool) => cancel_pool(&pool, member).await,
500 None => 0,
501 }
502 }
503
504 pub async fn cancel_by_id(&self, id: u64, member: Option<u64>) -> usize {
507 match self.registry.get_by_id(id) {
508 Some(pool) => cancel_pool(&pool, member).await,
509 None => 0,
510 }
511 }
512
513 pub async fn cancel_all(&self) -> usize {
516 let mut cancelled = 0;
517 for pool in self.registry.pools() {
518 cancelled += cancel_pool(&pool, None).await;
519 }
520 cancelled
521 }
522
523 pub async fn shutdown(&self) {
525 let task = self.lock_heartbeat().take();
526 if let Some(task) = task {
527 task.token.cancel();
528 let _ = task.handle.await;
529 }
530 let pools = self.registry.take_all();
531 drop(pools);
532 }
533
534 pub async fn query(&self, req: QueryRequest) -> Result<Value> {
548 let account = normalize_account(
549 req.account
550 .as_deref()
551 .or(self.config.default_account.as_deref())
552 .ok_or_else(|| {
553 anyhow!("no Snowflake account: pass --account or set SNOWFLAKE_ACCOUNT")
554 })?,
555 );
556 let user = req
557 .user
558 .as_deref()
559 .or(self.config.default_user.as_deref())
560 .ok_or_else(|| anyhow!("no Snowflake user: pass --user or set SNOWFLAKE_USER"))?
561 .trim()
562 .to_string();
563 validate_context(&req)?;
564
565 let key = SessionKey::new(account, user);
566 let overrides = req.overrides();
567 let pool = self.registry.get_or_create(&key, self.config.pool_size);
568
569 let cfg = self.config.clone();
575 let create_key = key.clone();
576 let auth_timeout = self.config.auth_timeout;
577 let checkout = pool
578 .checkout(move || async move {
579 match tokio::time::timeout(
581 auth_timeout,
582 create_session_with_base(&create_key, &cfg),
583 )
584 .await
585 {
586 Ok(result) => result,
587 Err(_) => Err(ClientError::Auth(format!(
588 "Snowflake sign-in timed out after {auth_timeout:?}"
589 ))),
590 }
591 })
592 .await
593 .map_err(|e| {
594 anyhow::Error::new(e).context(format!(
595 "failed to authenticate Snowflake session for {} / {}",
596 key.account, key.user
597 ))
598 })?;
599
600 if checkout
602 .session()
603 .session_expiring_within(TimeDelta::seconds(120))
604 && checkout.session().renew().await.is_err()
605 {
606 pool.discard(checkout);
607 return Err(anyhow!(
608 "Snowflake session expired and was discarded — re-run the query to re-authenticate"
609 ));
610 }
611
612 pool.start_query(
616 checkout.id(),
617 sql_preview(&req.sql, SQL_PREVIEW_MAX),
618 Some(checkout.session().abort_handle()),
619 );
620
621 let target = checkout.base().overlay(&overrides);
624 match run_with_renew(checkout.session(), checkout.current(), &target, &req.sql).await {
625 Ok(statements) => {
626 pool.touch();
627 pool.checkin(checkout, target);
628 Ok(client::rows_to_multi_payload(&statements))
629 }
630 Err(e) if e.is_session_expired() => {
631 pool.discard(checkout);
632 Err(anyhow!(
633 "Snowflake session expired and was discarded — re-run the query to re-authenticate"
634 ))
635 }
636 Err(e) => {
637 tracing::warn!("Snowflake query failed: {e}");
640 pool.checkin(checkout, QueryContext::default());
643 Err(anyhow::Error::new(e).context("Snowflake query failed"))
644 }
645 }
646 }
647}
648
649impl Drop for SnowflakeEngine {
650 fn drop(&mut self) {
651 let task = self.lock_heartbeat().take();
655 if let Some(task) = task {
656 task.token.cancel();
657 }
658 }
659}
660
661async fn cancel_pool(pool: &SessionPool, member: Option<u64>) -> usize {
666 let handles = pool.abort_handles(member);
667 let mut cancelled = 0;
668 for handle in handles {
669 match handle.abort().await {
670 Ok(true) => cancelled += 1,
671 Ok(false) => {}
672 Err(e) => tracing::warn!(pool = pool.id(), "Snowflake query cancel failed: {e}"),
673 }
674 }
675 cancelled
676}
677
678async fn heartbeat_all_pools(registry: &PoolRegistry, interval: Duration) {
681 for pool in registry.pools() {
682 let checkouts = pool.checkout_all_idle();
683 if checkouts.is_empty() {
684 continue;
685 }
686 let total = checkouts.len();
687 let mut kept = 0usize;
688 for checkout in checkouts {
689 if keep_session_alive(checkout.session(), interval).await {
690 pool.restore(checkout);
691 kept += 1;
692 } else {
693 pool.discard(checkout);
694 }
695 }
696 tracing::debug!(
697 pool = pool.id(),
698 kept,
699 total,
700 "Snowflake keep-alive heartbeat round"
701 );
702 }
703}
704
705async fn keep_session_alive(session: &SnowflakeSession, interval: Duration) -> bool {
713 let margin_secs = i64::try_from(interval.as_secs())
714 .unwrap_or(i64::MAX)
715 .saturating_add(HEARTBEAT_RENEW_MARGIN_SECS);
716 let margin = TimeDelta::try_seconds(margin_secs).unwrap_or(TimeDelta::MAX);
717 if session.session_expiring_within(margin) {
718 match session.renew().await {
719 Ok(()) => {}
720 Err(e) if e.is_session_expired() => {
721 tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
722 return false;
723 }
724 Err(e) => {
725 tracing::warn!("Snowflake keep-alive renew failed: {e}");
727 return true;
728 }
729 }
730 }
731 match session.heartbeat().await {
732 Ok(()) => true,
733 Err(e) if e.is_session_expired() => match session.renew().await {
734 Ok(()) => true,
735 Err(renew_err) if renew_err.is_session_expired() => {
736 tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
737 false
738 }
739 Err(renew_err) => {
740 tracing::warn!("Snowflake keep-alive renew failed: {renew_err}");
741 true
742 }
743 },
744 Err(e) => {
745 tracing::warn!("Snowflake keep-alive heartbeat failed: {e}");
746 true
747 }
748 }
749}
750
751async fn create_session_with_base(
754 key: &SessionKey,
755 config: &SnowflakeEngineConfig,
756) -> client::Result<(SnowflakeSession, QueryContext)> {
757 let mut cfg = SnowflakeClientConfig::external_browser(&key.account, &key.user);
758 cfg.auth = config.auth.clone();
759 cfg.host = config.default_host.clone();
760 cfg.warehouse = config.default_warehouse.clone();
761 cfg.role = config.default_role.clone();
762 cfg.database = config.default_database.clone();
763 cfg.schema = config.default_schema.clone();
764 cfg.http_timeout = config.http_timeout;
765 cfg.query_timeout = config.query_timeout;
766
767 let client = SnowflakeClient::new(cfg)?;
768 let session = client.create_session().await?;
769 session
770 .query("ALTER SESSION SET CLIENT_SESSION_KEEP_ALIVE = true")
771 .await?;
772 let base = capture_base_context(&session).await?;
773 Ok((session, base))
774}
775
776async fn capture_base_context(session: &SnowflakeSession) -> client::Result<QueryContext> {
779 let rows = session
780 .query("SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA()")
781 .await?;
782 let Some(row) = rows.first() else {
783 return Ok(QueryContext::default());
784 };
785 Ok(QueryContext {
786 warehouse: row.raw_at(0).map(str::to_string),
787 role: row.raw_at(1).map(str::to_string),
788 database: row.raw_at(2).map(str::to_string),
789 schema: row.raw_at(3).map(str::to_string),
790 })
791}
792
793async fn run_with_renew(
796 session: &SnowflakeSession,
797 current: &QueryContext,
798 target: &QueryContext,
799 sql: &str,
800) -> client::Result<Vec<Vec<Row>>> {
801 match apply_and_query(session, current, target, sql).await {
802 Err(e) if e.is_session_expired() => {
803 session.renew().await?;
804 apply_and_query(session, &QueryContext::default(), target, sql).await
806 }
807 other => other,
808 }
809}
810
811async fn apply_and_query(
814 session: &SnowflakeSession,
815 current: &QueryContext,
816 target: &QueryContext,
817 sql: &str,
818) -> client::Result<Vec<Vec<Row>>> {
819 apply_context(session, current, target).await?;
820 session.query_multi(sql).await
821}
822
823async fn apply_context(
827 session: &SnowflakeSession,
828 current: &QueryContext,
829 target: &QueryContext,
830) -> client::Result<()> {
831 for (keyword, cur, tgt) in [
832 (
833 "WAREHOUSE",
834 current.warehouse.as_deref(),
835 target.warehouse.as_deref(),
836 ),
837 ("ROLE", current.role.as_deref(), target.role.as_deref()),
838 (
839 "DATABASE",
840 current.database.as_deref(),
841 target.database.as_deref(),
842 ),
843 (
844 "SCHEMA",
845 current.schema.as_deref(),
846 target.schema.as_deref(),
847 ),
848 ] {
849 if let Some(name) = tgt {
850 if cur != Some(name) {
851 session
852 .query(format!("USE {keyword} {name}").as_str())
853 .await?;
854 }
855 }
856 }
857 Ok(())
858}
859
860fn normalize_account(account: &str) -> String {
862 account.trim().to_ascii_uppercase()
863}
864
865fn sql_preview(sql: &str, max: usize) -> String {
868 let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
869 if collapsed.chars().count() > max {
870 let head: String = collapsed.chars().take(max).collect();
871 format!("{}…", head.trim_end())
872 } else {
873 collapsed
874 }
875}
876
877fn validate_context(req: &QueryRequest) -> Result<()> {
880 for (name, value) in [
881 ("warehouse", req.warehouse.as_deref()),
882 ("role", req.role.as_deref()),
883 ("database", req.database.as_deref()),
884 ("schema", req.schema.as_deref()),
885 ] {
886 if let Some(value) = value {
887 validate_identifier(name, value)?;
888 }
889 }
890 Ok(())
891}
892
893fn validate_identifier(field: &str, value: &str) -> Result<()> {
897 if value.is_empty() {
898 bail!("--{field} must not be empty");
899 }
900 if !value
901 .chars()
902 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.'))
903 {
904 bail!(
905 "--{field} '{value}' is not a valid Snowflake identifier \
906 (allowed: letters, digits, '_', '$', '.')"
907 );
908 }
909 Ok(())
910}
911
912#[cfg(test)]
913#[allow(clippy::unwrap_used, clippy::expect_used)]
914mod tests {
915 use super::*;
916 use crate::test_support::env::MapEnv;
917
918 #[test]
919 fn default_config_has_a_nonzero_pool_size() {
920 assert!(SnowflakeEngineConfig::default().pool_size >= 1);
921 }
922
923 #[test]
924 fn heartbeat_interval_from_parses_seconds_zero_and_garbage() {
925 assert_eq!(heartbeat_interval_from(None), DEFAULT_HEARTBEAT_INTERVAL);
926 assert_eq!(
927 heartbeat_interval_from(Some("300".to_string())),
928 Duration::from_secs(300)
929 );
930 assert_eq!(
932 heartbeat_interval_from(Some(" 0 ".to_string())),
933 Duration::ZERO
934 );
935 assert_eq!(
936 heartbeat_interval_from(Some("garbage".to_string())),
937 DEFAULT_HEARTBEAT_INTERVAL
938 );
939 }
940
941 #[test]
942 fn host_override_from_trims_and_treats_blank_as_unset() {
943 assert_eq!(host_override_from(None), None);
944 assert_eq!(host_override_from(Some(" ".to_string())), None);
945 assert_eq!(
946 host_override_from(Some(
947 " acct.privatelink.snowflakecomputing.com\n".to_string()
948 )),
949 Some("acct.privatelink.snowflakecomputing.com".to_string())
950 );
951 }
952
953 #[test]
954 fn resolve_auth_method_defaults_to_external_browser() {
955 for selector in [
957 None,
958 Some(""),
959 Some(" "),
960 Some("externalbrowser"),
961 Some("EXTERNALBROWSER"),
962 ] {
963 assert!(matches!(
964 resolve_auth_method(selector, None, None, None, None).unwrap(),
965 AuthMethod::ExternalBrowser(BrowserConfig {
966 launch: BrowserLaunch::Auto,
967 ..
968 })
969 ));
970 }
971 }
972
973 #[test]
974 fn resolve_auth_method_threads_browser_command() {
975 let auth = resolve_auth_method(
976 Some("externalbrowser"),
977 Some(
978 "\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\" \
979 --profile-directory=\"Profile 1\" --new-window {url}"
980 .to_string(),
981 ),
982 None,
983 None,
984 None,
985 )
986 .unwrap();
987 let AuthMethod::ExternalBrowser(BrowserConfig {
988 launch: BrowserLaunch::Command(args),
989 ..
990 }) = auth
991 else {
992 panic!("expected an external-browser Command launch");
993 };
994 assert_eq!(
995 args,
996 vec![
997 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string(),
998 "--profile-directory=Profile 1".to_string(),
999 "--new-window".to_string(),
1000 "{url}".to_string(),
1001 ]
1002 );
1003 }
1004
1005 #[test]
1006 fn resolve_auth_method_blank_browser_command_is_auto() {
1007 assert!(matches!(
1009 resolve_auth_method(None, Some(" ".to_string()), None, None, None).unwrap(),
1010 AuthMethod::ExternalBrowser(BrowserConfig {
1011 launch: BrowserLaunch::Auto,
1012 ..
1013 })
1014 ));
1015 }
1016
1017 #[test]
1018 fn resolve_auth_method_rejects_a_malformed_browser_command() {
1019 let err = resolve_auth_method(None, Some("chrome \"--flag".to_string()), None, None, None)
1020 .unwrap_err();
1021 assert!(err.to_string().contains(ENV_BROWSER_COMMAND));
1022 }
1023
1024 #[test]
1025 fn resolve_auth_method_ignores_browser_command_for_non_interactive_methods() {
1026 assert!(matches!(
1028 resolve_auth_method(
1029 Some("pat"),
1030 Some("chrome {url}".to_string()),
1031 Some("tok".to_string()),
1032 None,
1033 None,
1034 )
1035 .unwrap(),
1036 AuthMethod::ProgrammaticAccessToken { .. }
1037 ));
1038 assert!(matches!(
1039 resolve_auth_method(
1040 Some("snowflake_jwt"),
1041 Some("chrome {url}".to_string()),
1042 None,
1043 Some("pem".to_string()),
1044 None,
1045 )
1046 .unwrap(),
1047 AuthMethod::KeyPairJwt(_)
1048 ));
1049 }
1050
1051 #[test]
1052 fn resolve_auth_method_reads_pat_and_trims_it() {
1053 let auth = resolve_auth_method(
1054 Some("programmatic_access_token"),
1055 None,
1056 Some(" tok-123\n".to_string()),
1057 None,
1058 None,
1059 )
1060 .unwrap();
1061 let AuthMethod::ProgrammaticAccessToken { token } = auth else {
1062 panic!("expected a PAT auth method");
1063 };
1064 assert_eq!(token.expose_secret(), "tok-123");
1065 }
1066
1067 #[test]
1068 fn resolve_auth_method_accepts_the_pat_alias() {
1069 assert!(matches!(
1070 resolve_auth_method(Some("pat"), None, Some("t".to_string()), None, None).unwrap(),
1071 AuthMethod::ProgrammaticAccessToken { .. }
1072 ));
1073 }
1074
1075 #[test]
1076 fn resolve_auth_method_errors_when_pat_is_missing_or_blank() {
1077 assert!(
1078 resolve_auth_method(Some("programmatic_access_token"), None, None, None, None).is_err()
1079 );
1080 assert!(
1081 resolve_auth_method(Some("pat"), None, Some(" ".to_string()), None, None).is_err()
1082 );
1083 }
1084
1085 #[test]
1086 fn resolve_auth_method_reads_key_pair_pem() {
1087 let auth = resolve_auth_method(
1088 Some("snowflake_jwt"),
1089 None,
1090 None,
1091 Some("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----".to_string()),
1092 None,
1093 )
1094 .unwrap();
1095 let AuthMethod::KeyPairJwt(cfg) = auth else {
1096 panic!("expected a key-pair auth method");
1097 };
1098 assert!(cfg
1099 .private_key_pem
1100 .expose_secret()
1101 .contains("BEGIN PRIVATE KEY"));
1102 }
1103
1104 #[test]
1105 fn resolve_auth_method_accepts_key_pair_aliases() {
1106 for selector in ["snowflake_jwt", "keypair", "key_pair", "jwt"] {
1107 assert!(matches!(
1108 resolve_auth_method(Some(selector), None, None, Some("pem".to_string()), None)
1109 .unwrap(),
1110 AuthMethod::KeyPairJwt(_)
1111 ));
1112 }
1113 }
1114
1115 #[test]
1116 fn resolve_auth_method_errors_when_key_is_missing() {
1117 assert!(resolve_auth_method(Some("snowflake_jwt"), None, None, None, None).is_err());
1118 assert!(resolve_auth_method(
1119 Some("snowflake_jwt"),
1120 None,
1121 None,
1122 Some(" ".to_string()),
1123 None
1124 )
1125 .is_err());
1126 }
1127
1128 #[test]
1129 fn resolve_auth_method_rejects_an_encrypted_key_passphrase() {
1130 let err = resolve_auth_method(
1131 Some("snowflake_jwt"),
1132 None,
1133 None,
1134 Some("pem".to_string()),
1135 Some("hunter2".to_string()),
1136 )
1137 .unwrap_err();
1138 assert!(err.to_string().contains(ENV_PRIVATE_KEY_PASSPHRASE));
1139 }
1140
1141 #[test]
1142 fn resolve_auth_method_rejects_an_unknown_selector() {
1143 let err = resolve_auth_method(Some("carrier-pigeon"), None, None, None, None).unwrap_err();
1144 assert!(err.to_string().contains("carrier-pigeon"));
1145 }
1146
1147 #[tokio::test]
1148 async fn start_heartbeat_is_a_noop_when_disabled() {
1149 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1150 heartbeat_interval: Duration::ZERO,
1151 ..SnowflakeEngineConfig::default()
1152 });
1153 engine.start_heartbeat();
1154 assert!(engine.lock_heartbeat().is_none());
1155 }
1156
1157 #[test]
1158 fn start_heartbeat_is_a_noop_outside_a_runtime() {
1159 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1160 engine.start_heartbeat();
1161 assert!(engine.lock_heartbeat().is_none());
1162 }
1163
1164 #[tokio::test]
1165 async fn start_heartbeat_is_idempotent_and_shutdown_stops_it() {
1166 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1167 engine.start_heartbeat();
1168 engine.lock_heartbeat().as_ref().unwrap().token.cancel();
1171 engine.start_heartbeat();
1172 assert!(
1173 engine
1174 .lock_heartbeat()
1175 .as_ref()
1176 .unwrap()
1177 .token
1178 .is_cancelled(),
1179 "second start must not replace the running task"
1180 );
1181 engine.shutdown().await;
1182 assert!(engine.lock_heartbeat().is_none());
1183 }
1184
1185 #[tokio::test]
1186 async fn cancel_selectors_are_zero_on_an_empty_engine() {
1187 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1190 assert_eq!(engine.cancel("ACCT", "me", None).await, 0);
1191 assert_eq!(engine.cancel("ACCT", "me", Some(3)).await, 0);
1192 assert_eq!(engine.cancel_by_id(7, None).await, 0);
1193 assert_eq!(engine.cancel_all().await, 0);
1194 assert_eq!(engine.pool_count(), 0, "cancel must not create a pool");
1195 }
1196
1197 #[tokio::test]
1198 async fn cancel_aborts_a_running_query_on_a_pooled_session() {
1199 use serde_json::json;
1200 use wiremock::matchers::{method, path};
1201 use wiremock::{Mock, MockServer, ResponseTemplate};
1202
1203 let server = MockServer::start().await;
1204 Mock::given(method("POST"))
1207 .and(path("/queries/v1/query-request"))
1208 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1209 "success": true, "code": "333333", "data": { "getResultUrl": "/poll/1" }
1210 })))
1211 .mount(&server)
1212 .await;
1213 Mock::given(method("GET"))
1214 .and(path("/poll/1"))
1215 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1216 "success": true, "code": "333333", "data": {}
1217 })))
1218 .mount(&server)
1219 .await;
1220 Mock::given(method("POST"))
1221 .and(path("/queries/v1/abort-request"))
1222 .respond_with(
1223 ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
1224 )
1225 .mount(&server)
1226 .await;
1227
1228 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1232 let pool = engine
1233 .registry
1234 .get_or_create(&SessionKey::new("ACCT", "me"), 4);
1235 let pool_id = pool.id();
1236 let uri = server.uri();
1237 let checkout = pool
1238 .checkout(|| async {
1239 Ok::<_, std::convert::Infallible>((
1240 client::test_session(&uri, Duration::from_secs(5)),
1241 QueryContext::default(),
1242 ))
1243 })
1244 .await
1245 .unwrap();
1246 let member_id = checkout.id();
1247 pool.start_query(
1248 member_id,
1249 "SELECT LONG".to_string(),
1250 Some(checkout.session().abort_handle()),
1251 );
1252
1253 tokio::select! {
1256 _ = checkout.session().query("SELECT LONG") => panic!("query should stay parked"),
1257 counts = async {
1258 tokio::time::sleep(Duration::from_millis(100)).await;
1260 let by_pair = engine.cancel("ACCT", "me", None).await;
1261 let by_id = engine.cancel_by_id(pool_id, Some(member_id)).await;
1262 let by_all = engine.cancel_all().await;
1263 (by_pair, by_id, by_all)
1264 } => {
1265 assert_eq!(counts, (1, 1, 1), "every selector aborts the running statement");
1266 }
1267 }
1268
1269 let aborts = server
1271 .received_requests()
1272 .await
1273 .unwrap()
1274 .iter()
1275 .filter(|r| r.url.path() == "/queries/v1/abort-request")
1276 .count();
1277 assert_eq!(aborts, 3);
1278 }
1279
1280 #[test]
1281 fn normalize_account_uppercases_and_trims() {
1282 assert_eq!(normalize_account(" my-org.acct "), "MY-ORG.ACCT");
1283 }
1284
1285 #[test]
1286 fn validate_identifier_accepts_bare_identifiers() {
1287 for value in ["WH", "my_wh", "DB.SCHEMA", "wh$1"] {
1288 assert!(validate_identifier("warehouse", value).is_ok(), "{value}");
1289 }
1290 }
1291
1292 #[test]
1293 fn validate_identifier_rejects_injection_and_empty() {
1294 for value in ["", "wh; DROP TABLE t", "wh OR 1=1", "wh'", "a b"] {
1295 assert!(validate_identifier("warehouse", value).is_err(), "{value}");
1296 }
1297 }
1298
1299 #[test]
1300 fn fill_defaults_from_fills_unset_fields() {
1301 let env = MapEnv::new()
1302 .with(ENV_ACCOUNT, "ACCT")
1303 .with(ENV_USER, "me")
1304 .with(ENV_WAREHOUSE, "WH")
1305 .with(ENV_ROLE, "R")
1306 .with(ENV_DATABASE, "DB")
1307 .with(ENV_SCHEMA, "S");
1308 let mut req = QueryRequest {
1309 sql: "SELECT 1".to_string(),
1310 ..QueryRequest::default()
1311 };
1312 req.fill_defaults_from(&env);
1313 assert_eq!(req.account.as_deref(), Some("ACCT"));
1314 assert_eq!(req.user.as_deref(), Some("me"));
1315 assert_eq!(req.warehouse.as_deref(), Some("WH"));
1316 assert_eq!(req.role.as_deref(), Some("R"));
1317 assert_eq!(req.database.as_deref(), Some("DB"));
1318 assert_eq!(req.schema.as_deref(), Some("S"));
1319 }
1320
1321 #[test]
1322 fn fill_defaults_from_keeps_explicit_values() {
1323 let env = MapEnv::new()
1324 .with(ENV_ACCOUNT, "ENV_ACCT")
1325 .with(ENV_USER, "env_user");
1326 let mut req = QueryRequest {
1327 account: Some("FLAG_ACCT".to_string()),
1328 sql: "SELECT 1".to_string(),
1329 ..QueryRequest::default()
1330 };
1331 req.fill_defaults_from(&env);
1332 assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
1333 assert_eq!(req.user.as_deref(), Some("env_user"));
1334 }
1335
1336 #[test]
1337 fn fill_defaults_from_leaves_unresolved_fields_none() {
1338 let mut req = QueryRequest {
1339 sql: "SELECT 1".to_string(),
1340 ..QueryRequest::default()
1341 };
1342 req.fill_defaults_from(&MapEnv::new());
1343 assert!(req.account.is_none());
1344 assert!(req.user.is_none());
1345 assert!(req.warehouse.is_none());
1346 }
1347
1348 #[test]
1349 fn query_request_serializes_without_none_fields() {
1350 let req = QueryRequest {
1351 account: Some("ACCT".to_string()),
1352 sql: "SELECT 1".to_string(),
1353 ..QueryRequest::default()
1354 };
1355 let value = serde_json::to_value(&req).unwrap();
1356 assert_eq!(
1357 value,
1358 serde_json::json!({ "account": "ACCT", "sql": "SELECT 1" })
1359 );
1360 }
1361
1362 #[test]
1363 fn validate_context_checks_each_present_flag() {
1364 let mut req = QueryRequest {
1365 sql: "SELECT 1".to_string(),
1366 ..QueryRequest::default()
1367 };
1368 assert!(validate_context(&req).is_ok());
1369 req.role = Some("good_role".to_string());
1370 assert!(validate_context(&req).is_ok());
1371 req.database = Some("bad; drop".to_string());
1372 assert!(validate_context(&req).is_err());
1373 }
1374
1375 #[tokio::test]
1376 async fn query_without_account_errors_without_network() {
1377 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1378 let err = engine
1379 .query(QueryRequest {
1380 sql: "SELECT 1".to_string(),
1381 ..QueryRequest::default()
1382 })
1383 .await
1384 .unwrap_err();
1385 assert!(err.to_string().contains("account"));
1386 }
1387
1388 #[tokio::test]
1389 async fn query_without_user_errors_without_network() {
1390 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1391 default_account: Some("ACCT".to_string()),
1392 ..SnowflakeEngineConfig::default()
1393 });
1394 let err = engine
1395 .query(QueryRequest {
1396 sql: "SELECT 1".to_string(),
1397 ..QueryRequest::default()
1398 })
1399 .await
1400 .unwrap_err();
1401 assert!(err.to_string().contains("user"));
1402 }
1403
1404 #[test]
1405 fn disconnect_and_sessions_on_empty_engine() {
1406 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1407 assert_eq!(engine.pool_count(), 0);
1408 assert!(engine.sessions().is_empty());
1409 assert!(!engine.disconnect("ACCT", "user"));
1410 assert!(!engine.disconnect_by_id(1));
1411 assert_eq!(engine.disconnect_all(), 0);
1412 }
1413
1414 #[test]
1415 fn sql_preview_collapses_whitespace_and_truncates() {
1416 assert_eq!(sql_preview("SELECT 1\n FROM t", 60), "SELECT 1 FROM t");
1418 let long = format!("SELECT {}", "a".repeat(100));
1420 let preview = sql_preview(&long, 20);
1421 assert!(preview.ends_with('…'));
1422 assert!(preview.chars().count() <= 21, "{preview}");
1423 }
1424
1425 #[test]
1426 fn overrides_extracts_only_the_set_dimensions() {
1427 let req = QueryRequest {
1428 warehouse: Some("WH".to_string()),
1429 schema: Some("S".to_string()),
1430 sql: "SELECT 1".to_string(),
1431 ..QueryRequest::default()
1432 };
1433 let overrides = req.overrides();
1434 assert_eq!(overrides.warehouse.as_deref(), Some("WH"));
1435 assert_eq!(overrides.schema.as_deref(), Some("S"));
1436 assert!(overrides.role.is_none());
1437 assert!(overrides.database.is_none());
1438 }
1439
1440 mod orchestration {
1441 use super::*;
1442 use serde_json::json;
1443 use wiremock::matchers::{method, path};
1444 use wiremock::{Mock, MockServer, ResponseTemplate};
1445
1446 async fn mount_query(server: &MockServer, data: serde_json::Value) {
1448 Mock::given(method("POST"))
1449 .and(path("/queries/v1/query-request"))
1450 .respond_with(
1451 ResponseTemplate::new(200)
1452 .set_body_json(json!({ "success": true, "data": data })),
1453 )
1454 .mount(server)
1455 .await;
1456 }
1457
1458 #[tokio::test]
1459 async fn capture_base_context_reads_current_context() {
1460 let server = MockServer::start().await;
1461 mount_query(
1462 &server,
1463 json!({
1464 "rowtype": [
1465 { "name": "CURRENT_WAREHOUSE()", "type": "text" },
1466 { "name": "CURRENT_ROLE()", "type": "text" },
1467 { "name": "CURRENT_DATABASE()", "type": "text" },
1468 { "name": "CURRENT_SCHEMA()", "type": "text" },
1469 ],
1470 "rowset": [["WH", "R", "DB", "S"]],
1471 }),
1472 )
1473 .await;
1474 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1475 let base = capture_base_context(&session).await.unwrap();
1476 assert_eq!(base.warehouse.as_deref(), Some("WH"));
1477 assert_eq!(base.role.as_deref(), Some("R"));
1478 assert_eq!(base.database.as_deref(), Some("DB"));
1479 assert_eq!(base.schema.as_deref(), Some("S"));
1480 }
1481
1482 #[tokio::test]
1483 async fn capture_base_context_defaults_when_no_rows() {
1484 let server = MockServer::start().await;
1485 mount_query(
1486 &server,
1487 json!({ "rowtype": [{ "name": "X", "type": "text" }], "rowset": [] }),
1488 )
1489 .await;
1490 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1491 assert_eq!(
1492 capture_base_context(&session).await.unwrap(),
1493 QueryContext::default()
1494 );
1495 }
1496
1497 #[tokio::test]
1498 async fn apply_context_issues_use_only_for_differing_dimensions() {
1499 let server = MockServer::start().await;
1500 mount_query(&server, json!({ "rowtype": [], "rowset": [] })).await;
1501 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1502
1503 let current = QueryContext {
1504 warehouse: Some("WH".to_string()),
1505 ..QueryContext::default()
1506 };
1507 let target = QueryContext {
1508 warehouse: Some("WH".to_string()), role: Some("R2".to_string()), ..QueryContext::default()
1511 };
1512 apply_context(&session, ¤t, &target).await.unwrap();
1513
1514 let reqs = server.received_requests().await.unwrap();
1515 assert_eq!(reqs.len(), 1, "only the differing dimension issues a USE");
1516 assert!(String::from_utf8_lossy(&reqs[0].body).contains("USE ROLE R2"));
1517 }
1518
1519 #[tokio::test]
1520 async fn run_with_renew_runs_without_renew_when_not_expired() {
1521 let server = MockServer::start().await;
1522 mount_query(
1523 &server,
1524 json!({ "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }),
1525 )
1526 .await;
1527 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1528 let ctx = QueryContext::default();
1529 let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1530 .await
1531 .unwrap();
1532 assert_eq!(rows.len(), 1);
1533 }
1534
1535 #[tokio::test]
1536 async fn run_with_renew_renews_and_retries_once_on_expiry() {
1537 let server = MockServer::start().await;
1538 Mock::given(method("POST"))
1540 .and(path("/queries/v1/query-request"))
1541 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1542 "success": false, "code": "390112", "message": "expired", "data": {}
1543 })))
1544 .up_to_n_times(1)
1545 .with_priority(1)
1546 .mount(&server)
1547 .await;
1548 Mock::given(method("POST"))
1550 .and(path("/session/token-request"))
1551 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1552 "success": true,
1553 "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1554 })))
1555 .mount(&server)
1556 .await;
1557 Mock::given(method("POST"))
1559 .and(path("/queries/v1/query-request"))
1560 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1561 "success": true,
1562 "data": { "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }
1563 })))
1564 .with_priority(2)
1565 .mount(&server)
1566 .await;
1567
1568 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1569 let ctx = QueryContext::default();
1570 let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1571 .await
1572 .unwrap();
1573 assert_eq!(rows.len(), 1, "renewed and retried transparently");
1574 }
1575 }
1576
1577 mod keep_alive {
1578 use super::*;
1579 use serde_json::json;
1580 use wiremock::matchers::{method, path};
1581 use wiremock::{Mock, MockServer, ResponseTemplate};
1582
1583 const INTERVAL: Duration = Duration::from_secs(60);
1586
1587 async fn mount_heartbeat(server: &MockServer, body: serde_json::Value) {
1589 Mock::given(method("POST"))
1590 .and(path("/session/heartbeat"))
1591 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1592 .mount(server)
1593 .await;
1594 }
1595
1596 async fn mount_renew(server: &MockServer, body: serde_json::Value) {
1598 Mock::given(method("POST"))
1599 .and(path("/session/token-request"))
1600 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1601 .mount(server)
1602 .await;
1603 }
1604
1605 fn ok_body() -> serde_json::Value {
1606 json!({ "success": true, "data": {} })
1607 }
1608
1609 fn renew_ok_body() -> serde_json::Value {
1610 json!({
1611 "success": true,
1612 "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1613 })
1614 }
1615
1616 fn expired_body() -> serde_json::Value {
1617 json!({ "success": false, "code": "390112", "message": "expired", "data": {} })
1618 }
1619
1620 #[tokio::test]
1621 async fn keep_session_alive_heartbeats_a_healthy_session() {
1622 let server = MockServer::start().await;
1623 mount_heartbeat(&server, ok_body()).await;
1624 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1625 assert!(keep_session_alive(&session, INTERVAL).await);
1626 let reqs = server.received_requests().await.unwrap();
1627 assert_eq!(reqs.len(), 1, "one heartbeat, no renew");
1628 }
1629
1630 #[tokio::test]
1631 async fn keep_session_alive_renews_when_the_heartbeat_reports_expiry() {
1632 let server = MockServer::start().await;
1633 mount_heartbeat(&server, expired_body()).await;
1634 mount_renew(&server, renew_ok_body()).await;
1635 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1636 assert!(keep_session_alive(&session, INTERVAL).await);
1637 let reqs = server.received_requests().await.unwrap();
1638 assert!(
1639 reqs.iter()
1640 .any(|r| r.url.path() == "/session/token-request"),
1641 "renewed after the expired heartbeat"
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn keep_session_alive_discards_when_the_master_token_is_dead() {
1647 let server = MockServer::start().await;
1648 mount_heartbeat(&server, expired_body()).await;
1649 mount_renew(&server, expired_body()).await;
1650 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1651 assert!(!keep_session_alive(&session, INTERVAL).await);
1652 }
1653
1654 #[tokio::test]
1655 async fn keep_session_alive_keeps_the_session_on_transient_errors() {
1656 let server = MockServer::start().await;
1657 mount_heartbeat(
1658 &server,
1659 json!({ "success": false, "code": "390001", "message": "hiccup", "data": {} }),
1660 )
1661 .await;
1662 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1663 assert!(keep_session_alive(&session, INTERVAL).await);
1664 }
1665
1666 #[tokio::test]
1667 async fn keep_session_alive_proactively_renews_a_token_expiring_before_the_next_tick() {
1668 let server = MockServer::start().await;
1669 mount_heartbeat(&server, ok_body()).await;
1670 mount_renew(&server, renew_ok_body()).await;
1671 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1672 assert!(keep_session_alive(&session, Duration::from_secs(7200)).await);
1674 let reqs = server.received_requests().await.unwrap();
1675 assert_eq!(
1676 reqs[0].url.path(),
1677 "/session/token-request",
1678 "renew ran first"
1679 );
1680 assert_eq!(reqs[1].url.path(), "/session/heartbeat");
1681 }
1682
1683 #[tokio::test]
1684 async fn engine_heartbeat_loop_beats_idle_sessions_and_stops_on_shutdown() {
1685 let server = MockServer::start().await;
1686 mount_heartbeat(&server, ok_body()).await;
1687
1688 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1689 heartbeat_interval: Duration::from_millis(50),
1690 ..SnowflakeEngineConfig::default()
1691 });
1692 let pool = engine
1694 .registry
1695 .get_or_create(&SessionKey::new("ACCT", "user"), 2);
1696 let uri = server.uri();
1697 let checkout = pool
1698 .checkout(|| async {
1699 Ok::<_, std::convert::Infallible>((
1700 client::test_session(&uri, Duration::from_secs(5)),
1701 QueryContext::default(),
1702 ))
1703 })
1704 .await
1705 .unwrap();
1706 pool.checkin(checkout, QueryContext::default());
1707
1708 engine.start_heartbeat();
1709 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1710 while server.received_requests().await.unwrap().is_empty() {
1711 assert!(
1712 tokio::time::Instant::now() < deadline,
1713 "no heartbeat within the deadline"
1714 );
1715 tokio::time::sleep(Duration::from_millis(10)).await;
1716 }
1717 assert_eq!(pool.live(), 1);
1719
1720 engine.shutdown().await;
1721 let after = server.received_requests().await.unwrap().len();
1722 tokio::time::sleep(Duration::from_millis(150)).await;
1723 assert_eq!(
1724 server.received_requests().await.unwrap().len(),
1725 after,
1726 "no heartbeats after shutdown"
1727 );
1728 assert_eq!(engine.pool_count(), 0, "pools drained on shutdown");
1729 }
1730 }
1731}