1use crate::client::errors::ConnectionError;
39use crate::config::{Profile, SslMode};
40use crate::info;
41use mz_postgres_util::Sql;
42use std::collections::BTreeMap;
43use tokio_postgres::types::ToSql;
44use tokio_postgres::{Client as PgClient, NoTls, Row, SimpleQueryMessage, Transaction};
45
46pub struct Client {
55 client: PgClient,
56 profile: Profile,
57 auto_scaling_support: std::sync::OnceLock<bool>,
58}
59
60pub struct DeploymentsClient<'a> {
62 pub(crate) client: &'a Client,
63}
64
65pub struct DeploymentsClientMut<'a> {
67 pub(crate) client: &'a mut Client,
68}
69
70pub struct IntrospectionClient<'a> {
72 pub(crate) client: &'a Client,
73}
74
75pub struct ValidationClient<'a> {
77 pub(crate) client: &'a Client,
78}
79
80pub struct TypeInfoClient<'a> {
82 pub(crate) client: &'a Client,
83}
84
85pub struct ProvisioningClient<'a> {
87 pub(crate) client: &'a Client,
88}
89
90pub struct DevOverlaysClient<'a> {
92 pub(crate) client: &'a Client,
93}
94
95const APPLICATION_NAME: &str = "mz-deploy";
96
97impl Client {
98 pub async fn connect_with_profile(profile: Profile) -> Result<Self, ConnectionError> {
110 Self::connect_with_profile_inner(profile, true).await
111 }
112
113 pub(crate) async fn connect_with_profile_no_pin(
124 profile: Profile,
125 ) -> Result<Self, ConnectionError> {
126 Self::connect_with_profile_inner(profile, false).await
127 }
128
129 async fn connect_with_profile_inner(
130 profile: Profile,
131 pin_server_cluster: bool,
132 ) -> Result<Self, ConnectionError> {
133 let host = profile.require_host()?;
134 let mut config = tokio_postgres::Config::new();
135 config.host(host);
136 config.port(profile.port);
137 config.user(&profile.username);
138 config.dbname("materialize");
139 if let Some(password) = &profile.password {
140 config.password(password.as_str());
141 }
142 config.application_name(APPLICATION_NAME);
143
144 let mut effective_options = profile.options.clone();
145 if pin_server_cluster {
146 effective_options.insert(
147 "cluster".to_string(),
148 crate::client::SERVER_CLUSTER_NAME.to_string(),
149 );
150 }
151 if let Some(inner) = build_options_string(&effective_options) {
152 config.options(&inner);
153 }
154
155 let mode = profile.sslmode.unwrap_or_else(|| default_sslmode(host));
156 let hunt: Vec<&std::path::Path> =
157 DEFAULT_CA_PATHS.iter().map(std::path::Path::new).collect();
158 let spec = plan_connector(mode, profile.sslrootcert.as_deref(), host, &hunt, |p| {
159 p.exists()
160 })?;
161 let connector = build_connector(spec)?;
162
163 config.ssl_mode(tokio_ssl_mode(mode));
164
165 type BoxConnection =
169 Box<dyn Future<Output = Result<(), tokio_postgres::Error>> + Send + Unpin>;
170 let (client, connection): (PgClient, BoxConnection) = match connector {
171 Connector::NoTls => {
172 let (client, connection) = config
173 .connect(NoTls)
174 .await
175 .map_err(|source| classify_connect_error(source, &profile, mode))?;
176 (client, Box::new(connection))
177 }
178 Connector::Tls(tls) => {
179 let (client, connection) = config
180 .connect(tls)
181 .await
182 .map_err(|source| classify_connect_error(source, &profile, mode))?;
183 (client, Box::new(connection))
184 }
185 };
186
187 mz_ore::task::spawn(|| "mz-deploy-connection", async move {
188 if let Err(e) = connection.await {
189 info!("connection error: {}", e);
190 }
191 });
192
193 Ok(Client {
194 client,
195 profile,
196 auto_scaling_support: std::sync::OnceLock::new(),
197 })
198 }
199
200 pub(crate) async fn supports_auto_scaling_strategies(&self) -> Result<bool, ConnectionError> {
202 if let Some(supported) = self.auto_scaling_support.get() {
203 return Ok(*supported);
204 }
205 let row = self
206 .query_one(
207 r#"
208 SELECT EXISTS(
209 SELECT 1
210 FROM mz_catalog.mz_objects AS o
211 JOIN mz_catalog.mz_schemas AS s ON o.schema_id = s.id
212 WHERE o.name = 'mz_cluster_auto_scaling_strategies'
213 AND o.type = 'materialized-view'
214 AND s.name = 'mz_internal'
215 ) AS exists
216 "#,
217 &[],
218 )
219 .await?;
220 let supported: bool = row.get("exists");
221 let _ = self.auto_scaling_support.set(supported);
222 Ok(supported)
223 }
224
225 pub fn profile(&self) -> &Profile {
227 &self.profile
228 }
229
230 pub(crate) async fn begin_transaction(&mut self) -> Result<Transaction<'_>, ConnectionError> {
232 self.client
233 .transaction()
234 .await
235 .map_err(ConnectionError::Query)
236 }
237
238 pub fn deployments(&self) -> DeploymentsClient<'_> {
240 DeploymentsClient { client: self }
241 }
242
243 pub fn deployments_mut(&mut self) -> DeploymentsClientMut<'_> {
245 DeploymentsClientMut { client: self }
246 }
247
248 pub fn introspection(&self) -> IntrospectionClient<'_> {
250 IntrospectionClient { client: self }
251 }
252
253 pub fn validation(&self) -> ValidationClient<'_> {
255 ValidationClient { client: self }
256 }
257
258 pub fn types(&self) -> TypeInfoClient<'_> {
260 TypeInfoClient { client: self }
261 }
262
263 pub fn provisioning(&self) -> ProvisioningClient<'_> {
265 ProvisioningClient { client: self }
266 }
267
268 pub fn dev_overlays(&self) -> DevOverlaysClient<'_> {
270 DevOverlaysClient { client: self }
271 }
272
273 pub async fn execute(
275 &self,
276 statement: &str,
277 params: &[&(dyn ToSql + Sync)],
278 ) -> Result<u64, ConnectionError> {
279 mz_postgres_util::execute(
280 &self.client,
281 Sql::raw_unchecked(statement.to_string()),
282 params,
283 )
284 .await
285 .map_err(ConnectionError::from)
286 }
287
288 pub async fn query_one(
290 &self,
291 statement: &str,
292 params: &[&(dyn ToSql + Sync)],
293 ) -> Result<Row, ConnectionError> {
294 mz_postgres_util::query_one(
295 &self.client,
296 Sql::raw_unchecked(statement.to_string()),
297 params,
298 )
299 .await
300 .map_err(ConnectionError::from)
301 }
302
303 pub async fn query(
305 &self,
306 statement: &str,
307 params: &[&(dyn ToSql + Sync)],
308 ) -> Result<Vec<Row>, ConnectionError> {
309 mz_postgres_util::query(
310 &self.client,
311 Sql::raw_unchecked(statement.to_string()),
312 params,
313 )
314 .await
315 .map_err(ConnectionError::from)
316 }
317
318 pub async fn simple_query(
320 &self,
321 query: &str,
322 ) -> Result<Vec<SimpleQueryMessage>, ConnectionError> {
323 mz_postgres_util::simple_query(&self.client, Sql::raw_unchecked(query.to_string()))
324 .await
325 .map_err(ConnectionError::from)
326 }
327
328 pub async fn batch_execute(&self, query: &str) -> Result<(), ConnectionError> {
330 mz_postgres_util::batch_execute(&self.client, Sql::raw_unchecked(query.to_string()))
331 .await
332 .map_err(ConnectionError::from)
333 }
334}
335
336const DEFAULT_CA_PATHS: &[&str] = &[
341 "/etc/ssl/cert.pem", "/opt/homebrew/etc/openssl@3/cert.pem", "/usr/local/etc/openssl@3/cert.pem", "/opt/homebrew/etc/openssl/cert.pem", "/usr/local/etc/openssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/ca-bundle.pem", ];
350
351pub(crate) fn default_sslmode(host: &str) -> SslMode {
358 if is_loopback_host(host) {
359 SslMode::Prefer
360 } else {
361 SslMode::Require
362 }
363}
364
365pub(crate) fn is_loopback_host(host: &str) -> bool {
371 if host == "localhost" {
372 return true;
373 }
374 let unbracketed = host
375 .strip_prefix('[')
376 .and_then(|s| s.strip_suffix(']'))
377 .unwrap_or(host);
378 if let Ok(ip) = unbracketed.parse::<std::net::IpAddr>() {
379 return ip.is_loopback();
380 }
381 false
382}
383
384fn tokio_ssl_mode(mode: SslMode) -> tokio_postgres::config::SslMode {
385 use tokio_postgres::config::SslMode as TokioMode;
386 match mode {
387 SslMode::Disable => TokioMode::Disable,
388 SslMode::Prefer => TokioMode::Prefer,
389 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull => TokioMode::Require,
390 }
391}
392
393#[derive(Debug)]
395enum HostCheck {
396 Dns(String),
398 Ip(std::net::IpAddr),
400}
401
402#[derive(Debug)]
405enum ConnectorSpec {
406 NoTls,
407 Tls {
408 verify: openssl::ssl::SslVerifyMode,
409 host_check: Option<HostCheck>,
410 ca_source: CaSource,
411 },
412}
413
414#[derive(Debug)]
417enum CaSource {
418 None,
420 Explicit(std::path::PathBuf),
422 Hunted(std::path::PathBuf),
424 DefaultVerifyPaths,
428}
429
430enum Connector {
432 NoTls,
433 Tls(postgres_openssl::MakeTlsConnector),
434}
435
436impl std::fmt::Debug for Connector {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 match self {
439 Connector::NoTls => write!(f, "Connector::NoTls"),
440 Connector::Tls(_) => write!(f, "Connector::Tls(...)"),
441 }
442 }
443}
444
445fn plan_connector(
455 mode: SslMode,
456 sslrootcert: Option<&std::path::Path>,
457 host: &str,
458 hunt_candidates: &[&std::path::Path],
459 ca_exists: impl Fn(&std::path::Path) -> bool,
460) -> Result<ConnectorSpec, ConnectionError> {
461 use openssl::ssl::SslVerifyMode;
462
463 match mode {
464 SslMode::Disable => Ok(ConnectorSpec::NoTls),
465 SslMode::Prefer | SslMode::Require => Ok(ConnectorSpec::Tls {
466 verify: SslVerifyMode::NONE,
467 host_check: None,
468 ca_source: CaSource::None,
469 }),
470 SslMode::VerifyCa | SslMode::VerifyFull => {
471 let ca_source = resolve_ca_source(sslrootcert, hunt_candidates, ca_exists)?;
472 let host_check = if matches!(mode, SslMode::VerifyFull) {
473 Some(match host.parse::<std::net::IpAddr>() {
474 Ok(ip) => HostCheck::Ip(ip),
475 Err(_) => HostCheck::Dns(host.to_string()),
476 })
477 } else {
478 None
479 };
480 Ok(ConnectorSpec::Tls {
481 verify: SslVerifyMode::PEER,
482 host_check,
483 ca_source,
484 })
485 }
486 }
487}
488
489fn resolve_ca_source(
490 explicit: Option<&std::path::Path>,
491 hunt_candidates: &[&std::path::Path],
492 ca_exists: impl Fn(&std::path::Path) -> bool,
493) -> Result<CaSource, ConnectionError> {
494 if let Some(path) = explicit {
495 if ca_exists(path) {
496 return Ok(CaSource::Explicit(path.to_path_buf()));
497 } else {
498 return Err(ConnectionError::TlsCaNotFound);
499 }
500 }
501 for candidate in hunt_candidates {
502 if ca_exists(candidate) {
503 return Ok(CaSource::Hunted(candidate.to_path_buf()));
504 }
505 }
506 Ok(CaSource::DefaultVerifyPaths)
507}
508
509fn build_connector(spec: ConnectorSpec) -> Result<Connector, ConnectionError> {
512 use openssl::ssl::{SslConnector, SslMethod};
513
514 match spec {
515 ConnectorSpec::NoTls => Ok(Connector::NoTls),
516 ConnectorSpec::Tls {
517 verify,
518 host_check,
519 ca_source,
520 } => {
521 let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|e| {
522 ConnectionError::Message(format!("Failed to create TLS builder: {}", e))
523 })?;
524
525 match ca_source {
526 CaSource::None => {}
527 CaSource::Explicit(path) | CaSource::Hunted(path) => {
528 builder
529 .set_ca_file(&path)
530 .map_err(|_| ConnectionError::TlsCaNotFound)?;
531 }
532 CaSource::DefaultVerifyPaths => {
533 builder
534 .set_default_verify_paths()
535 .map_err(|_| ConnectionError::TlsCaNotFound)?;
536 }
537 }
538
539 builder.set_verify(verify);
540
541 if let Some(check) = host_check {
542 let param = builder.verify_param_mut();
543 match check {
544 HostCheck::Dns(name) => {
545 param
546 .set_host(&name)
547 .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
548 }
549 HostCheck::Ip(ip) => {
550 param
551 .set_ip(ip)
552 .map_err(|e| ConnectionError::Message(format!("{}", e)))?;
553 }
554 }
555 }
556
557 Ok(Connector::Tls(postgres_openssl::MakeTlsConnector::new(
558 builder.build(),
559 )))
560 }
561 }
562}
563
564fn classify_connect_error(
575 source: tokio_postgres::Error,
576 profile: &Profile,
577 mode: SslMode,
578) -> ConnectionError {
579 let host = profile.host.clone().unwrap_or_default();
582 if matches!(mode, SslMode::VerifyCa | SslMode::VerifyFull) {
583 if let Some(ssl_msg) = ssl_error_in_chain(&source) {
584 let hostname_suffix = if ssl_msg.contains("hostname mismatch")
585 || ssl_msg.contains("Hostname mismatch")
586 || ssl_msg.contains("IP address mismatch")
587 {
588 " (hostname mismatch)"
589 } else {
590 ""
591 };
592 return ConnectionError::TlsVerification {
593 host,
594 port: profile.port,
595 hostname_suffix,
596 source,
597 };
598 }
599 }
600
601 if matches!(
602 mode,
603 SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull
604 ) && message_indicates_tls_refused(&source)
605 {
606 return ConnectionError::TlsRequiredNotSupported {
607 host,
608 port: profile.port,
609 source,
610 };
611 }
612
613 ConnectionError::Connect {
614 host,
615 port: profile.port,
616 source,
617 }
618}
619
620fn ssl_error_in_chain(err: &tokio_postgres::Error) -> Option<String> {
623 let mut cur: &(dyn std::error::Error + 'static) = err;
624 while let Some(source) = std::error::Error::source(cur) {
625 if source.is::<openssl::error::ErrorStack>() {
626 return Some(source.to_string());
627 }
628 cur = source;
629 }
630 None
631}
632
633fn message_indicates_tls_refused(err: &tokio_postgres::Error) -> bool {
639 matches_tls_refused_message(&err.to_string())
640}
641
642fn matches_tls_refused_message(msg: &str) -> bool {
649 msg.contains("TLS was required")
650 || msg.contains("server does not support TLS")
651 || msg.contains("server does not support SSL")
652}
653
654fn escape_options_value(value: &str) -> String {
661 let mut out = String::with_capacity(value.len());
662 for c in value.chars() {
663 match c {
664 '\\' => out.push_str(r"\\"),
665 ' ' => out.push_str(r"\ "),
666 other => out.push(other),
667 }
668 }
669 out
670}
671
672pub(crate) fn build_options_string(options: &BTreeMap<String, String>) -> Option<String> {
679 if options.is_empty() {
680 return None;
681 }
682 let joined = options
683 .iter()
684 .map(|(k, v)| format!("-c {k}={}", escape_options_value(v)))
685 .collect::<Vec<_>>()
686 .join(" ");
687 Some(joined)
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[mz_ore::test]
695 fn test_escape_options_value_plain() {
696 assert_eq!(escape_options_value("prod"), "prod");
697 }
698
699 #[mz_ore::test]
700 fn test_escape_options_value_space() {
701 assert_eq!(escape_options_value("prod cluster"), r"prod\ cluster");
702 }
703
704 #[mz_ore::test]
705 fn test_escape_options_value_backslash() {
706 assert_eq!(escape_options_value(r"a\b"), r"a\\b");
707 }
708
709 #[mz_ore::test]
710 fn test_escape_options_value_mixed() {
711 assert_eq!(escape_options_value(r"a \b"), r"a\ \\b");
713 }
714
715 #[mz_ore::test]
716 fn test_build_options_string_empty() {
717 let options: BTreeMap<String, String> = BTreeMap::new();
718 assert_eq!(build_options_string(&options), None);
719 }
720
721 #[mz_ore::test]
722 fn test_build_options_string_single() {
723 let mut options = BTreeMap::new();
724 options.insert("cluster".to_string(), "prod".to_string());
725 assert_eq!(
726 build_options_string(&options),
727 Some("-c cluster=prod".to_string())
728 );
729 }
730
731 #[mz_ore::test]
732 fn test_build_options_string_multiple_sorted() {
733 let mut options = BTreeMap::new();
734 options.insert("search_path".to_string(), "public".to_string());
736 options.insert("cluster".to_string(), "prod".to_string());
737 assert_eq!(
738 build_options_string(&options),
739 Some("-c cluster=prod -c search_path=public".to_string())
740 );
741 }
742
743 #[mz_ore::test]
744 fn test_build_options_string_escapes_value_space() {
745 let mut options = BTreeMap::new();
746 options.insert("cluster".to_string(), "prod cluster".to_string());
747 assert_eq!(
748 build_options_string(&options),
749 Some(r"-c cluster=prod\ cluster".to_string())
750 );
751 }
752
753 #[mz_ore::test]
754 fn test_build_options_string_escapes_value_backslash() {
755 let mut options = BTreeMap::new();
756 options.insert("cluster".to_string(), r"a\b".to_string());
757 assert_eq!(
758 build_options_string(&options),
759 Some(r"-c cluster=a\\b".to_string())
760 );
761 }
762
763 use std::path::Path;
764
765 #[mz_ore::test]
766 fn plan_disable_produces_notls() {
767 let spec = plan_connector(SslMode::Disable, None, "example.com", &[], |_| false).unwrap();
768 assert!(matches!(spec, ConnectorSpec::NoTls));
769 }
770
771 #[mz_ore::test]
772 fn plan_prefer_and_require_have_verify_none_and_no_ca() {
773 for mode in [SslMode::Prefer, SslMode::Require] {
774 let spec = plan_connector(mode, None, "example.com", &[], |_| true).unwrap();
775 match spec {
776 ConnectorSpec::Tls {
777 verify,
778 host_check,
779 ca_source,
780 } => {
781 assert_eq!(verify, openssl::ssl::SslVerifyMode::NONE);
782 assert!(host_check.is_none());
783 assert!(matches!(ca_source, CaSource::None));
784 }
785 ConnectorSpec::NoTls => panic!("expected Tls for {:?}, got NoTls", mode),
786 }
787 }
788 }
789
790 #[mz_ore::test]
791 fn plan_verify_ca_has_peer_verify_no_host_check() {
792 let spec = plan_connector(
793 SslMode::VerifyCa,
794 None,
795 "example.com",
796 &[Path::new("/does/not/exist"), Path::new("/tmp/fake-ca.pem")],
797 |p| p == Path::new("/tmp/fake-ca.pem"),
798 )
799 .unwrap();
800 match spec {
801 ConnectorSpec::Tls {
802 verify,
803 host_check,
804 ca_source,
805 } => {
806 assert_eq!(verify, openssl::ssl::SslVerifyMode::PEER);
807 assert!(host_check.is_none());
808 assert!(
809 matches!(ca_source, CaSource::Hunted(p) if p == Path::new("/tmp/fake-ca.pem"))
810 );
811 }
812 ConnectorSpec::NoTls => panic!("expected Tls, got NoTls"),
813 }
814 }
815
816 #[mz_ore::test]
817 fn plan_verify_full_dns_host_check() {
818 let spec = plan_connector(
819 SslMode::VerifyFull,
820 None,
821 "example.com",
822 &[Path::new("/tmp/fake-ca.pem")],
823 |_| true,
824 )
825 .unwrap();
826 match spec {
827 ConnectorSpec::Tls {
828 host_check: Some(HostCheck::Dns(ref name)),
829 ..
830 } => assert_eq!(name, "example.com"),
831 other => panic!("expected Tls with Dns host check, got {:?}", other),
832 }
833 }
834
835 #[mz_ore::test]
836 fn plan_verify_full_ip_host_check() {
837 let spec = plan_connector(
838 SslMode::VerifyFull,
839 None,
840 "10.0.0.5",
841 &[Path::new("/tmp/fake-ca.pem")],
842 |_| true,
843 )
844 .unwrap();
845 match spec {
846 ConnectorSpec::Tls {
847 host_check: Some(HostCheck::Ip(ip)),
848 ..
849 } => assert_eq!(ip, "10.0.0.5".parse::<std::net::IpAddr>().unwrap()),
850 other => panic!("expected Tls with Ip host check, got {:?}", other),
851 }
852 }
853
854 #[mz_ore::test]
855 fn plan_explicit_sslrootcert_wins_over_hunt() {
856 let explicit = std::path::PathBuf::from("/my/ca.pem");
857 let spec = plan_connector(
858 SslMode::VerifyCa,
859 Some(&explicit),
860 "example.com",
861 &[Path::new("/tmp/should-be-ignored.pem")],
862 |p| p == explicit.as_path(),
863 )
864 .unwrap();
865 match spec {
866 ConnectorSpec::Tls {
867 ca_source: CaSource::Explicit(p),
868 ..
869 } => assert_eq!(p, explicit),
870 other => panic!("expected Tls/Explicit, got {:?}", other),
871 }
872 }
873
874 #[mz_ore::test]
875 fn plan_explicit_sslrootcert_missing_is_ca_not_found() {
876 let explicit = std::path::PathBuf::from("/no/such/file.pem");
877 let err = plan_connector(
878 SslMode::VerifyCa,
879 Some(&explicit),
880 "example.com",
881 &[Path::new("/tmp/fake-ca.pem")],
882 |_| false,
883 )
884 .unwrap_err();
885 assert!(matches!(err, ConnectionError::TlsCaNotFound));
886 }
887
888 #[mz_ore::test]
889 fn plan_no_ca_sources_at_all_falls_back_to_default_verify_paths() {
890 let spec = plan_connector(
891 SslMode::VerifyFull,
892 None,
893 "example.com",
894 &[Path::new("/nope1"), Path::new("/nope2")],
895 |_| false,
896 )
897 .unwrap();
898 match spec {
899 ConnectorSpec::Tls {
900 ca_source: CaSource::DefaultVerifyPaths,
901 ..
902 } => {}
903 other => panic!("expected Tls/DefaultVerifyPaths, got {:?}", other),
904 }
905 }
906
907 #[mz_ore::test]
908 fn build_disable_returns_notls() {
909 let connector = build_connector(ConnectorSpec::NoTls).unwrap();
910 assert!(matches!(connector, Connector::NoTls));
911 }
912
913 #[cfg_attr(miri, ignore)] #[mz_ore::test]
915 fn build_prefer_returns_tls_no_ca_work() {
916 let connector = build_connector(ConnectorSpec::Tls {
917 verify: openssl::ssl::SslVerifyMode::NONE,
918 host_check: None,
919 ca_source: CaSource::None,
920 })
921 .unwrap();
922 assert!(matches!(connector, Connector::Tls(_)));
923 }
924
925 #[cfg_attr(miri, ignore)] #[mz_ore::test]
927 fn build_explicit_missing_ca_returns_ca_not_found() {
928 let err = build_connector(ConnectorSpec::Tls {
929 verify: openssl::ssl::SslVerifyMode::PEER,
930 host_check: None,
931 ca_source: CaSource::Explicit(std::path::PathBuf::from("/absolutely/not/a/real/file")),
932 })
933 .unwrap_err();
934 assert!(matches!(err, ConnectionError::TlsCaNotFound));
935 }
936
937 #[mz_ore::test]
938 fn matches_tls_refused_tls_was_required() {
939 assert!(matches_tls_refused_message(
940 "some prefix: TLS was required but not provided"
941 ));
942 }
943
944 #[mz_ore::test]
945 fn matches_tls_refused_does_not_support_tls() {
946 assert!(matches_tls_refused_message(
947 "error: server does not support TLS"
948 ));
949 }
950
951 #[mz_ore::test]
952 fn matches_tls_refused_does_not_support_ssl() {
953 assert!(matches_tls_refused_message(
954 "error: server does not support SSL"
955 ));
956 }
957
958 #[mz_ore::test]
959 fn matches_tls_refused_unrelated_message() {
960 assert!(!matches_tls_refused_message("connection refused"));
961 assert!(!matches_tls_refused_message("database does not exist"));
962 assert!(!matches_tls_refused_message(""));
963 }
964}