pg_embedded_setup_unpriv/cluster/
connection.rs1use camino::{Utf8Path, Utf8PathBuf};
4#[cfg(feature = "diesel-support")]
5use color_eyre::eyre::WrapErr;
6use postgres::{Client, NoTls};
7use postgresql_embedded::Settings;
8
9use crate::{TestBootstrapSettings, error::BootstrapResult};
10
11pub(crate) fn escape_identifier(name: &str) -> String { name.replace('"', "\"\"") }
16
17pub(crate) fn connect_admin(url: &str) -> BootstrapResult<Client> {
22 Client::connect(url, NoTls).map_err(admin_connect_error)
23}
24
25pub(crate) fn admin_connect_error(err: postgres::Error) -> crate::error::BootstrapError {
32 let reason = std::error::Error::source(&err)
33 .map_or_else(|| err.to_string(), |source| format!("{err}: {source}"));
34 let message = format!("failed to connect to admin database: {reason}");
35 crate::error::BootstrapError::from(color_eyre::Report::new(err).wrap_err(message))
36}
37
38#[derive(Debug, Clone)]
52pub struct ConnectionMetadata {
53 settings: Settings,
54 pgpass_file: Utf8PathBuf,
55}
56
57impl ConnectionMetadata {
58 pub(crate) fn from_settings(settings: &TestBootstrapSettings) -> Self {
59 Self {
60 settings: settings.settings.clone(),
61 pgpass_file: settings.environment.pgpass_file.clone(),
62 }
63 }
64
65 #[must_use]
67 pub const fn host(&self) -> &str { self.settings.host.as_str() }
68
69 #[must_use]
71 pub const fn port(&self) -> u16 { self.settings.port }
72
73 #[must_use]
75 pub const fn superuser(&self) -> &str { self.settings.username.as_str() }
76
77 #[must_use]
79 pub const fn password(&self) -> &str { self.settings.password.as_str() }
80
81 #[must_use]
83 pub fn pgpass_file(&self) -> &Utf8Path { self.pgpass_file.as_ref() }
84
85 #[must_use]
88 pub fn database_url(&self, database: &str) -> String { self.settings.url(database) }
89}
90
91#[derive(Debug, Clone)]
108pub struct TestClusterConnection {
109 metadata: ConnectionMetadata,
110}
111
112impl TestClusterConnection {
113 pub(crate) fn new(settings: &TestBootstrapSettings) -> Self {
114 Self {
115 metadata: ConnectionMetadata::from_settings(settings),
116 }
117 }
118
119 #[must_use]
121 pub const fn host(&self) -> &str { self.metadata.host() }
122
123 #[must_use]
125 pub const fn port(&self) -> u16 { self.metadata.port() }
126
127 #[must_use]
129 pub const fn superuser(&self) -> &str { self.metadata.superuser() }
130
131 #[must_use]
133 pub const fn password(&self) -> &str { self.metadata.password() }
134
135 #[must_use]
137 pub fn pgpass_file(&self) -> &Utf8Path { self.metadata.pgpass_file() }
138
139 #[must_use]
141 pub fn metadata(&self) -> ConnectionMetadata { self.metadata.clone() }
142
143 #[must_use]
145 pub fn database_url(&self, database: &str) -> String { self.metadata.database_url(database) }
146
147 #[cfg(feature = "diesel-support")]
152 pub fn diesel_connection(&self, database: &str) -> BootstrapResult<diesel::PgConnection> {
153 use diesel::Connection;
154
155 diesel::PgConnection::establish(&self.database_url(database))
156 .wrap_err(format!("failed to connect to {database} via Diesel"))
157 .map_err(crate::error::BootstrapError::from)
158 }
159
160 pub(super) fn admin_client(&self) -> BootstrapResult<Client> {
162 connect_admin(&self.database_url("postgres"))
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use std::time::Duration;
170
171 use postgresql_embedded::Settings;
172
173 use super::*;
174 use crate::{
175 CleanupMode,
176 TestBootstrapSettings,
177 bootstrap::{ExecutionMode, ExecutionPrivileges, TestBootstrapEnvironment},
178 };
179
180 fn sample_settings() -> TestBootstrapSettings {
181 let settings = Settings {
182 host: "127.0.0.1".into(),
183 port: 55_321,
184 username: "fixture_user".into(),
185 password: "fixture_pass".into(),
186 data_dir: "/tmp/cluster-data".into(),
187 installation_dir: "/tmp/cluster-install".into(),
188 ..Settings::default()
189 };
190
191 TestBootstrapSettings {
192 privileges: ExecutionPrivileges::Unprivileged,
193 execution_mode: ExecutionMode::InProcess,
194 settings,
195 environment: TestBootstrapEnvironment {
196 home: Utf8PathBuf::from("/tmp/home"),
197 xdg_cache_home: Utf8PathBuf::from("/tmp/home/cache"),
198 xdg_runtime_dir: Utf8PathBuf::from("/tmp/home/run"),
199 pgpass_file: Utf8PathBuf::from("/tmp/home/.pgpass"),
200 tz_dir: Some(Utf8PathBuf::from("/usr/share/zoneinfo")),
201 timezone: "UTC".into(),
202 },
203 worker_binary: None,
204 setup_timeout: Duration::from_secs(1),
205 start_timeout: Duration::from_secs(1),
206 shutdown_timeout: Duration::from_secs(1),
207 cleanup_mode: CleanupMode::default(),
208 binary_cache_dir: None,
209 extensions: None,
210 installed_extensions: Vec::new(),
211 }
212 }
213
214 #[test]
215 fn metadata_reflects_underlying_settings() {
216 let settings = sample_settings();
217 let connection = TestClusterConnection::new(&settings);
218 let metadata = connection.metadata();
219
220 assert_eq!(metadata.host(), "127.0.0.1");
221 assert_eq!(metadata.port(), 55_321);
222 assert_eq!(metadata.superuser(), "fixture_user");
223 assert_eq!(metadata.password(), "fixture_pass");
224 assert_eq!(metadata.pgpass_file(), Utf8Path::new("/tmp/home/.pgpass"));
225 }
226
227 #[test]
228 fn database_url_matches_postgresql_embedded() {
229 let settings = sample_settings();
230 let connection = TestClusterConnection::new(&settings);
231 let expected = settings.settings.url("postgres");
232
233 assert_eq!(connection.database_url("postgres"), expected);
234 }
235
236 fn reject_password_once() -> std::io::Result<std::net::SocketAddr> {
239 let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
240 let address = listener.local_addr()?;
241 std::thread::spawn(move || {
242 let _served = answer_with_28p01(&listener);
245 });
246 Ok(address)
247 }
248
249 #[expect(
252 clippy::big_endian_bytes,
253 reason = "the PostgreSQL wire protocol frames every length in network byte order"
254 )]
255 fn answer_with_28p01(listener: &std::net::TcpListener) -> std::io::Result<()> {
256 use std::io::{Error, Read, Write};
257
258 let (mut stream, _) = listener.accept()?;
259 let mut length = [0_u8; 4];
260 stream.read_exact(&mut length)?;
261 let body_len = usize::try_from(u32::from_be_bytes(length))
262 .map_err(Error::other)?
263 .saturating_sub(4);
264 let mut body = vec![0_u8; body_len];
265 stream.read_exact(&mut body)?;
266 let mut fields = Vec::new();
267 for (code, value) in [
268 (b'S', "FATAL"),
269 (b'V', "FATAL"),
270 (b'C', "28P01"),
271 (b'M', "password authentication failed for user \"postgres\""),
272 ] {
273 fields.push(code);
274 fields.extend_from_slice(value.as_bytes());
275 fields.push(0);
276 }
277 fields.push(0);
278 let reply_len = u32::try_from(fields.len() + 4).map_err(Error::other)?;
279 let mut reply = vec![b'E'];
280 reply.extend_from_slice(&reply_len.to_be_bytes());
281 reply.extend_from_slice(&fields);
282 stream.write_all(&reply)
283 }
284
285 #[test]
288 fn a_rejected_password_names_its_cause() {
289 let address = reject_password_once().expect("a loopback port");
290 let url = format!("postgresql://postgres:wrong@{address}/postgres");
291
292 let err = connect_admin(&url)
293 .err()
294 .expect("the server rejects the password");
295
296 let message = err.to_string();
297 assert!(
298 message.contains("db error: FATAL: password authentication failed"),
299 "the nested cause must reach the message: {message}"
300 );
301 let report = err.into_report();
302 let driver = report
303 .chain()
304 .find_map(|cause| cause.downcast_ref::<postgres::Error>())
305 .expect("the postgres error stays in the chain");
306 let code = driver.code().map(postgres::error::SqlState::code);
307 assert_eq!(
308 code,
309 Some("28P01"),
310 "the server's code survives: {report:?}"
311 );
312 }
313
314 #[test]
317 fn a_failed_admin_connection_keeps_its_source() {
318 let err = connect_admin("postgresql://postgres:x@127.0.0.1:1/postgres")
320 .err()
321 .expect("nothing listens on port 1");
322
323 let message = err.to_string();
324 let report = err.into_report();
325 let chain_has_driver_error = report
326 .chain()
327 .any(|cause| cause.downcast_ref::<postgres::Error>().is_some());
328 assert!(chain_has_driver_error, "the source was dropped: {report:?}");
329 assert!(
330 message.starts_with("failed to connect to admin database: "),
331 "{message}"
332 );
333 assert!(
334 message.to_lowercase().contains("refused"),
335 "the cause must reach the message: {message}"
336 );
337 }
338}