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