Skip to main content

max / makenotwork

25.6 KB · 638 lines History Blame Raw
1 //! Postgres work the gates need: the scratch database they run against, the
2 //! backup restore that seeds it, and the URL surgery both require.
3 //!
4 //! A peer of the gate families rather than one family's helper.
5 //! [`clean_stale_test_dbs`] is called from both `cargo_test` and
6 //! `hardening_test`, and [`pg_url_with_dbname`] from the migration check as
7 //! well as from code_smoke.
8
9 use super::log::GateLog;
10 use anyhow::{Context, Result};
11 use ops_exec::sh_quote;
12 use tokio::process::Command;
13
14 pub(crate) async fn reset_scratch(db_url: &str, owner_role: &str) -> Result<()> {
15 use sqlx::Executor;
16 use sqlx::postgres::PgPoolOptions;
17 let pool = PgPoolOptions::new()
18 .max_connections(1)
19 .connect(db_url)
20 .await?;
21 // `owner_role` is validated `[A-Za-z0-9_]+` at config load, so interpolating
22 // it into DDL is sound. It still goes through `format('%I')` inside the DO
23 // block for the quoting Postgres expects on an identifier.
24 let sql = format!(
25 r#"
26 DO $$
27 DECLARE s text;
28 BEGIN
29 -- The dump restores objects owned by the prod role and re-grants to
30 -- it (`ALTER ... OWNER TO {owner_role}`), which errors if the role
31 -- is absent — superuser does not imply the role exists. Create it
32 -- NOLOGIN: the scratch DB needs the role as an *owner* only, never
33 -- as a connecting identity. Idempotent, so a re-reset is a no-op.
34 IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{owner_role}') THEN
35 EXECUTE format('CREATE ROLE %I NOLOGIN', '{owner_role}');
36 END IF;
37
38 -- Drop every non-system schema, not just public — migrations create
39 -- custom schemas (e.g. tower_sessions) that survive `DROP SCHEMA
40 -- public CASCADE` and then collide on the next migration run.
41 FOR s IN
42 SELECT nspname FROM pg_namespace
43 WHERE nspname NOT LIKE 'pg_%'
44 AND nspname NOT IN ('information_schema')
45 LOOP
46 EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE', s);
47 END LOOP;
48 EXECUTE 'CREATE SCHEMA public';
49 -- Restore the pre-PG15 public-schema default on the throwaway
50 -- scratch DB. Without this, the freshly-created public is owned by
51 -- the connecting role (sando) with no grant to anyone else, so a
52 -- migration's FK/trigger check that Postgres runs as a *restored*
53 -- prod-owned table's owner ({owner_role} from the backup dump)
54 -- fails with "permission denied for schema public". Granting to
55 -- PUBLIC is role-agnostic and safe here — this DB is disposable and
56 -- exists only to dry-run migrations.
57 EXECUTE 'GRANT USAGE, CREATE ON SCHEMA public TO PUBLIC';
58 -- PG15+: the new owner needs CREATE on public in its own right, not
59 -- only via PUBLIC, for the restore's owner-scoped DDL.
60 EXECUTE format('GRANT USAGE, CREATE ON SCHEMA public TO %I', '{owner_role}');
61 END $$;
62 "#
63 );
64 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(sql)))
65 .await?;
66 pool.close().await;
67 Ok(())
68 }
69
70 /// Startup assertion for the scratch cluster: the gates reset it, seed an owner
71 /// role into it, and drop leftover test databases in it, none of which a plain
72 /// unprivileged role can do. Satisfied by hand on the build host
73 /// (`ALTER ROLE sando SUPERUSER`, a created `makenotwork` role); unasserted, a
74 /// rebuild elsewhere fails one gate at a time with an opaque permissions error.
75 /// Assert once, at boot, loudly.
76 ///
77 /// Not part of `--check-config`: that path is pure by design (no DB, no
78 /// network), and a green there must mean "this build understands its config",
79 /// not "the cluster is reachable".
80 pub async fn preflight_scratch_privileges(db_url: &str) -> Result<()> {
81 use sqlx::postgres::PgPoolOptions;
82 let pool = PgPoolOptions::new()
83 .max_connections(1)
84 .connect(db_url)
85 .await
86 .context("connecting to scratch_db_url for the startup privilege check")?;
87 let (is_super, can_signal): (bool, bool) = sqlx::query_as(
88 "SELECT rolsuper, pg_catalog.pg_has_role(current_user, 'pg_signal_backend', 'USAGE')
89 FROM pg_roles WHERE rolname = current_user",
90 )
91 .fetch_one(&pool)
92 .await?;
93 pool.close().await;
94 anyhow::ensure!(
95 is_super || can_signal,
96 "the scratch_db_url role has neither SUPERUSER nor pg_signal_backend; migration_dry_run \
97 and cargo_test cannot reset the scratch DB or clear stale test databases. Grant one:\n \
98 ALTER ROLE <role> SUPERUSER; -- what fw13 uses\n \
99 GRANT pg_signal_backend TO <role>; -- narrower: terminate only, cannot drop \
100 foreign-owned databases",
101 );
102 if !is_super {
103 tracing::warn!(
104 "scratch role has pg_signal_backend but not SUPERUSER: stale test databases owned by \
105 another role cannot be dropped, and the scratch owner role cannot be created if absent"
106 );
107 }
108 Ok(())
109 }
110
111 /// Best-effort cleanup of stale per-test database clones (`mnw_test_<uuid>`)
112 /// left behind by a killed `cargo_test` run.
113 ///
114 /// Drops **foreign-owned leftovers too**, which is why the daemon asserts
115 /// SUPERUSER at startup (`preflight_scratch_privileges`): `DROP DATABASE`
116 /// requires ownership or superuser, and the `WITH (FORCE)` terminate requires
117 /// superuser or `pg_signal_backend`. Without both, orphans from a run under a
118 /// different role accumulate and degrade the gate — the failure this cleanup
119 /// exists to prevent.
120 ///
121 /// OPERATIONAL HAZARD: fw13 runs one Postgres cluster shared with local `cargo
122 /// test` as `max`, so a gate firing mid-local-test will force-drop that run's
123 /// databases out from under it. That collision is known and tracked separately
124 /// (give the gate its own cluster); until then, do not run local tests on fw13
125 /// while a Sando gate is live.
126 ///
127 /// Deliberately **excludes the template** (`mnw_test_template_*`): the harness
128 /// reuses it across runs when it's migration-current (skipping a full
129 /// drop+migrate), so dropping it here would force a needless rebuild every
130 /// gate run. Templates are bounded (one per role) and never accumulate, so
131 /// leaving them is free. Never returns an error: a cleanup miss must not turn a
132 /// deploy red.
133 pub(super) async fn clean_stale_test_dbs(db_url: &str) {
134 use sqlx::Executor;
135 use sqlx::postgres::PgPoolOptions;
136 let pool = match PgPoolOptions::new()
137 .max_connections(1)
138 .connect(db_url)
139 .await
140 {
141 Ok(p) => p,
142 Err(e) => {
143 tracing::warn!(error = %e, "stale test-db cleanup: could not connect; skipping");
144 return;
145 }
146 };
147 // Every per-test clone, whoever owns it. The ownership filter this used to
148 // carry is what let foreign-owned orphans pile up; superuser (asserted at
149 // startup) makes them droppable.
150 let names: Vec<(String,)> = sqlx::query_as(
151 "SELECT datname FROM pg_database
152 WHERE datname LIKE 'mnw_test_%'
153 AND datname NOT LIKE '%template%'",
154 )
155 .fetch_all(&pool)
156 .await
157 .unwrap_or_default();
158 let count = names.len();
159 for (name,) in names {
160 // `name` comes straight from pg_database; quoting it is sufficient.
161 if let Err(e) = pool
162 .execute(sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
163 "DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)"
164 ))))
165 .await
166 {
167 tracing::warn!(error = %e, db = %name, "stale test-db cleanup: drop failed");
168 }
169 }
170 if count > 0 {
171 tracing::info!(
172 count,
173 "stale test-db cleanup: dropped leftover mnw_test_* databases"
174 );
175 }
176 pool.close().await;
177 }
178
179 /// Build the restore shell line. Two pipelines we accept:
180 /// *.sql -> psql -v ON_ERROR_STOP=1 $url < dump
181 /// *.sql.gz -> set -o pipefail; gunzip -c dump | psql -v ON_ERROR_STOP=1 $url
182 ///
183 /// Two safety flags are load-bearing (CF4):
184 /// - `ON_ERROR_STOP=1`: without it, psql exits 0 even when individual statements
185 /// error, so a partial/corrupt restore would *pass* the gate.
186 /// - `set -o pipefail`: without it a shell pipeline reports only the last
187 /// command's status, so a `gunzip` failure on a truncated archive is masked by
188 /// psql's exit. pipefail is a bash builtin (not POSIX sh), so the runner uses
189 /// `bash -c`.
190 pub(super) fn restore_shell(db_url: &str, dump: &str) -> String {
191 if std::path::Path::new(dump)
192 .extension()
193 .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
194 {
195 format!(
196 "set -o pipefail; gunzip -c {q} | psql -v ON_ERROR_STOP=1 {url}",
197 q = sh_quote(dump),
198 url = sh_quote(db_url),
199 )
200 } else {
201 format!(
202 "psql -v ON_ERROR_STOP=1 {url} < {q}",
203 url = sh_quote(db_url),
204 q = sh_quote(dump),
205 )
206 }
207 }
208
209 pub(super) async fn restore_dump(db_url: &str, dump: &str, log: &GateLog) -> Result<()> {
210 // Split the password out of the URL and hand it to psql via PGPASSWORD, so it
211 // never lands in argv (visible in /proc/<pid>/cmdline to any local user).
212 // The sanitized URL — user/host/db, no secret — goes on the command line.
213 let (sanitized, password) = split_pg_password(db_url);
214 let shell = restore_shell(&sanitized, dump);
215 // `bash` (not `sh`): `set -o pipefail` is a bash builtin. The restore runs
216 // locally on the Sando host (fw13), which has bash.
217 let mut cmd = Command::new("bash");
218 cmd.arg("-c").arg(&shell);
219 // kill_on_drop so the gate's wall-clock ceiling (dispatcher-level timeout on
220 // migration_dry_run) can't orphan a wedged psql restore.
221 cmd.kill_on_drop(true);
222 if let Some(pw) = password {
223 cmd.env("PGPASSWORD", pw);
224 }
225 // Streamed, not `.output()`: a prod-sized restore runs for minutes, and
226 // psql's progress is the only thing an operator has to watch during it.
227 let (_stdout, stderr, status) = log.run(&mut cmd).await?;
228 anyhow::ensure!(
229 status.success(),
230 "restore failed: {}",
231 String::from_utf8_lossy(&stderr),
232 );
233 Ok(())
234 }
235
236 /// Split a `postgres://user:password@host/db` URL into its password-free form and
237 /// the (percent-decoded) password. Returns the URL unchanged with `None` when
238 /// there is no userinfo password. psql reads the password from `PGPASSWORD`, so
239 /// keeping it off the command line removes the /proc exposure.
240 pub(super) fn split_pg_password(db_url: &str) -> (String, Option<String>) {
241 let Some(after) = db_url.find("://").map(|i| i + 3) else {
242 return (db_url.to_string(), None);
243 };
244 // The authority ends at the first '/', '?' or '#'; the password (if any) is
245 // between the first ':' and the '@' within the userinfo of that authority.
246 let authority_end = db_url[after..]
247 .find(['/', '?', '#'])
248 .map_or(db_url.len(), |i| after + i);
249 let Some(at) = db_url[after..authority_end].find('@').map(|i| after + i) else {
250 return (db_url.to_string(), None);
251 };
252 let userinfo = &db_url[after..at];
253 let Some(colon) = userinfo.find(':') else {
254 return (db_url.to_string(), None);
255 };
256 let password = percent_decode(&userinfo[colon + 1..]);
257 let sanitized = format!(
258 "{}{}{}",
259 &db_url[..after],
260 &userinfo[..colon],
261 &db_url[at..]
262 );
263 (sanitized, Some(password))
264 }
265
266 /// Minimal `%XX` percent-decode for a URL userinfo component. Non-escape bytes
267 /// pass through; a malformed escape is left literal.
268 pub(super) fn percent_decode(s: &str) -> String {
269 let b = s.as_bytes();
270 let mut out = Vec::with_capacity(b.len());
271 let mut i = 0;
272 while i < b.len() {
273 if b[i] == b'%'
274 && i + 2 < b.len()
275 && let (Some(h), Some(l)) = (hex_val(b[i + 1]), hex_val(b[i + 2]))
276 {
277 out.push((h << 4) | l);
278 i += 3;
279 } else {
280 out.push(b[i]);
281 i += 1;
282 }
283 }
284 String::from_utf8_lossy(&out).into_owned()
285 }
286
287 pub(super) fn hex_val(c: u8) -> Option<u8> {
288 match c {
289 b'0'..=b'9' => Some(c - b'0'),
290 b'a'..=b'f' => Some(c - b'a' + 10),
291 b'A'..=b'F' => Some(c - b'A' + 10),
292 _ => None,
293 }
294 }
295
296 pub(crate) async fn run_migrator(db_url: &str, dir: &std::path::Path) -> Result<()> {
297 use sqlx::postgres::PgPoolOptions;
298 let pool = PgPoolOptions::new()
299 .max_connections(1)
300 .connect(db_url)
301 .await?;
302 let migrator = sqlx::migrate::Migrator::new(dir).await?;
303 migrator.run(&pool).await?;
304 pool.close().await;
305 Ok(())
306 }
307
308 /// Rewrite a `postgres://` URL to point at database `dbname`, preserving scheme,
309 /// userinfo, host/port, and any query (e.g. the socket `?host=/var/run/postgresql`
310 /// form) + fragment. Used to derive the maintenance connection (`postgres`) and
311 /// the throwaway smoke DB URL from the configured `scratch_db_url`.
312 pub(super) fn pg_url_with_dbname(url: &str, dbname: &str) -> String {
313 let Some(after_scheme) = url.find("://").map(|i| i + 3) else {
314 return url.to_string();
315 };
316 let rest = &url[after_scheme..];
317 // Authority ends at the first '/', '?' or '#'; whatever follows is the
318 // path (the old dbname) plus an optional query/fragment we must keep.
319 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
320 let authority = &rest[..auth_end];
321 let tail = &rest[auth_end..];
322 let query_and_frag = match tail.find(['?', '#']) {
323 Some(i) => &tail[i..],
324 None => "",
325 };
326 format!(
327 "{}{}/{}{}",
328 &url[..after_scheme],
329 authority,
330 dbname,
331 query_and_frag
332 )
333 }
334
335 /// Create the throwaway smoke DB on the cluster `maintenance_url` points at,
336 /// dropping any stale one first. `dbname` is sanitized to `[a-z0-9_]` by
337 /// `code_smoke_db_name`, so quoting it is sufficient. `CREATE DATABASE` cannot
338 /// run inside a transaction, so these go through the simple-query protocol (a
339 /// raw `&str` execute), matching `reset_scratch`.
340 pub(super) async fn pg_create_db(maintenance_url: &str, dbname: &str) -> Result<()> {
341 use sqlx::Executor;
342 use sqlx::postgres::PgPoolOptions;
343 let pool = PgPoolOptions::new()
344 .max_connections(1)
345 .connect(maintenance_url)
346 .await?;
347 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
348 "DROP DATABASE IF EXISTS \"{dbname}\" WITH (FORCE)"
349 ))))
350 .await?;
351 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
352 "CREATE DATABASE \"{dbname}\""
353 ))))
354 .await?;
355 pool.close().await;
356 Ok(())
357 }
358
359 /// Drop the throwaway smoke DB, forcing off any lingering connection (the killed
360 /// server's pool). Best-effort at the call site — a failure is logged, not fatal.
361 pub(super) async fn pg_drop_db(maintenance_url: &str, dbname: &str) -> Result<()> {
362 use sqlx::Executor;
363 use sqlx::postgres::PgPoolOptions;
364 let pool = PgPoolOptions::new()
365 .max_connections(1)
366 .connect(maintenance_url)
367 .await?;
368 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
369 "DROP DATABASE IF EXISTS \"{dbname}\" WITH (FORCE)"
370 ))))
371 .await?;
372 pool.close().await;
373 Ok(())
374 }
375
376 #[cfg(test)]
377 mod tests {
378 use super::*;
379
380 /// reset_scratch must drop every non-system schema, not just `public` —
381 /// otherwise migrations that create custom schemas (e.g. tower_sessions)
382 /// collide on the next run. This regressed once (Phase 0) and the fix is
383 /// load-bearing for migration_dry_run.
384 ///
385 /// Gated on `SANDO_TEST_PG_URL` so it only runs where postgres is
386 /// available. Set `SANDO_TEST_PG_URL=postgres:///sando_scratch?host=/var/run/postgresql`
387 /// (or similar) before `cargo test`.
388 #[tokio::test]
389 async fn reset_scratch_drops_all_non_system_schemas() {
390 let Ok(url) = std::env::var("SANDO_TEST_PG_URL") else {
391 eprintln!("skipping: SANDO_TEST_PG_URL not set");
392 return;
393 };
394 use sqlx::Executor;
395 use sqlx::postgres::PgPoolOptions;
396
397 let pool = PgPoolOptions::new()
398 .max_connections(1)
399 .connect(&url)
400 .await
401 .unwrap();
402 // Plant two non-system schemas + a table in each.
403 pool.execute(
404 "DROP SCHEMA IF EXISTS foo CASCADE; CREATE SCHEMA foo; CREATE TABLE foo.t (i int);",
405 )
406 .await
407 .unwrap();
408 pool.execute("DROP SCHEMA IF EXISTS tower_sessions CASCADE; CREATE SCHEMA tower_sessions; CREATE TABLE tower_sessions.session (id text);")
409 .await.unwrap();
410 pool.close().await;
411
412 reset_scratch(&url, "makenotwork")
413 .await
414 .expect("reset_scratch");
415
416 let pool = PgPoolOptions::new()
417 .max_connections(1)
418 .connect(&url)
419 .await
420 .unwrap();
421 let rows: Vec<(String,)> = sqlx::query_as(
422 "SELECT nspname FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema'",
423 )
424 .fetch_all(&pool)
425 .await
426 .unwrap();
427 let names: Vec<String> = rows.into_iter().map(|(s,)| s).collect();
428 // After reset, only `public` should remain among non-system schemas.
429 assert_eq!(names, vec!["public".to_string()], "got: {names:?}");
430 pool.close().await;
431 }
432
433 /// reset_scratch must leave the dump's owner role existing and able to
434 /// create in `public`, because a prod `pg_dump` carries `ALTER ... OWNER TO
435 /// <role>` for every object. This was satisfied by a hand-created NOLOGIN
436 /// role on fw13; nothing recorded it, so any other box failed
437 /// migration_dry_run at the restore with "role does not exist".
438 ///
439 /// Uses a throwaway role name so it can prove the *creation* path rather
440 /// than passing on fw13's pre-existing `makenotwork`. Same
441 /// `SANDO_TEST_PG_URL` gate as above; needs a superuser connection.
442 #[tokio::test]
443 async fn reset_scratch_seeds_the_dump_owner_role_when_absent() {
444 let Ok(url) = std::env::var("SANDO_TEST_PG_URL") else {
445 eprintln!("skipping: SANDO_TEST_PG_URL not set");
446 return;
447 };
448 use sqlx::Executor;
449 use sqlx::postgres::PgPoolOptions;
450
451 let role = "sando_test_owner_probe";
452 // `DROP ROLE` refuses while the role still holds the grants reset_scratch
453 // gave it, so drop what it owns first. Idempotent, and a no-op when the
454 // role is absent (the usual case on a first run).
455 let drop_role = format!(
456 "DO $$ BEGIN
457 IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
458 EXECUTE 'DROP OWNED BY {role}';
459 EXECUTE 'DROP ROLE {role}';
460 END IF;
461 END $$;"
462 );
463
464 let pool = PgPoolOptions::new()
465 .max_connections(1)
466 .connect(&url)
467 .await
468 .unwrap();
469 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(drop_role.clone())))
470 .await
471 .unwrap();
472 pool.close().await;
473
474 reset_scratch(&url, role)
475 .await
476 .expect("reset_scratch creates the owner role");
477
478 let pool = PgPoolOptions::new()
479 .max_connections(1)
480 .connect(&url)
481 .await
482 .unwrap();
483 let (exists, can_login): (bool, bool) =
484 sqlx::query_as("SELECT true, rolcanlogin FROM pg_roles WHERE rolname = $1")
485 .bind(role)
486 .fetch_one(&pool)
487 .await
488 .expect("owner role exists after reset");
489 assert!(exists);
490 assert!(
491 !can_login,
492 "the owner role is an owner only, never a login identity"
493 );
494
495 // The restore's owner-scoped DDL needs CREATE on public in the role's
496 // own right (PG15+ dropped the implicit grant).
497 let (has_create,): (bool,) =
498 sqlx::query_as("SELECT pg_catalog.has_schema_privilege($1, 'public', 'CREATE')")
499 .bind(role)
500 .fetch_one(&pool)
501 .await
502 .unwrap();
503 assert!(has_create, "owner role must be able to create in public");
504
505 // Idempotent: a second reset must not error on the now-existing role.
506 pool.close().await;
507 reset_scratch(&url, role)
508 .await
509 .expect("reset_scratch is idempotent");
510
511 let pool = PgPoolOptions::new()
512 .max_connections(1)
513 .connect(&url)
514 .await
515 .unwrap();
516 pool.execute(sqlx::raw_sql(sqlx::AssertSqlSafe(drop_role)))
517 .await
518 .unwrap();
519 pool.close().await;
520 }
521
522 /// The preflight must pass against a privileged scratch connection. Guards
523 /// the catalog query itself: a wrong column or a `current_user` that matches
524 /// no `pg_roles` row would make `fetch_one` error (or, worse, a silently
525 /// swapped pair would invert the check) and brick startup for everyone.
526 /// `SANDO_TEST_PG_URL` is expected to be a superuser connection, as the
527 /// gates require.
528 #[tokio::test]
529 async fn preflight_passes_on_a_privileged_scratch_connection() {
530 let Ok(url) = std::env::var("SANDO_TEST_PG_URL") else {
531 eprintln!("skipping: SANDO_TEST_PG_URL not set");
532 return;
533 };
534 preflight_scratch_privileges(&url)
535 .await
536 .expect("a superuser scratch connection must satisfy the preflight");
537 }
538
539 /// CF4: the restore pipeline must carry `ON_ERROR_STOP=1` (so psql fails on
540 /// a bad statement instead of exiting 0 on a partial restore) and, for a
541 /// gzip source, `set -o pipefail` (so a `gunzip` failure on a truncated
542 /// archive isn't masked by psql's exit). Pure string check — no postgres.
543 #[test]
544 fn restore_shell_has_error_stop_and_pipefail() {
545 let gz = restore_shell("postgres:///scratch", "/srv/sando/backups/latest.sql.gz");
546 assert!(gz.contains("ON_ERROR_STOP=1"), "gz: {gz}");
547 assert!(gz.contains("set -o pipefail"), "gz: {gz}");
548 assert!(gz.contains("gunzip -c"), "gz: {gz}");
549
550 let plain = restore_shell("postgres:///scratch", "/srv/sando/backups/dump.sql");
551 assert!(plain.contains("ON_ERROR_STOP=1"), "plain: {plain}");
552 // No pipeline for a plain .sql, so pipefail is unnecessary there.
553 assert!(!plain.contains("gunzip"), "plain: {plain}");
554 // The db url is single-quote escaped in both forms.
555 assert!(plain.contains("'postgres:///scratch'"), "plain: {plain}");
556 }
557
558 #[test]
559 fn split_pg_password_extracts_and_sanitizes() {
560 // Password lifted out of the URL; the sanitized form keeps user/host/db.
561 let (url, pw) = split_pg_password("postgres://sando:s3cret@db.host:5432/scratch");
562 assert_eq!(url, "postgres://sando@db.host:5432/scratch");
563 assert_eq!(pw.as_deref(), Some("s3cret"));
564 // Percent-encoded password is decoded for PGPASSWORD.
565 let (url, pw) = split_pg_password("postgresql://u:p%40ss%2Fword@h/d");
566 assert_eq!(url, "postgresql://u@h/d");
567 assert_eq!(pw.as_deref(), Some("p@ss/word"));
568 }
569
570 #[test]
571 fn split_pg_password_noop_without_password() {
572 // No userinfo password -> unchanged, None. (A ':' after the '@', e.g. a
573 // port, must not be mistaken for the password delimiter.)
574 assert_eq!(
575 split_pg_password("postgres:///scratch"),
576 ("postgres:///scratch".to_string(), None),
577 );
578 assert_eq!(
579 split_pg_password("postgres://sando@db.host:5432/scratch"),
580 ("postgres://sando@db.host:5432/scratch".to_string(), None),
581 );
582 }
583
584 #[test]
585 fn percent_decode_handles_escapes_and_malformed() {
586 assert_eq!(percent_decode("plain"), "plain");
587 assert_eq!(percent_decode("a%2Fb"), "a/b");
588 // A malformed trailing escape is left literal, not dropped.
589 assert_eq!(percent_decode("ab%2"), "ab%2");
590 assert_eq!(percent_decode("ab%zz"), "ab%zz");
591 }
592
593 #[test]
594 fn pg_url_with_dbname_rewrites_the_database() {
595 // user:pass@host:port/db?query — swap db, keep everything else.
596 assert_eq!(
597 pg_url_with_dbname(
598 "postgres://sando:pw@db.host:5432/sando_scratch?sslmode=require",
599 "postgres"
600 ),
601 "postgres://sando:pw@db.host:5432/postgres?sslmode=require",
602 );
603 // Socket form: the query carries `host=/var/run/postgresql` and must survive.
604 assert_eq!(
605 pg_url_with_dbname(
606 "postgres:///sando_scratch?host=/var/run/postgresql",
607 "sando_code_smoke_0_9_6"
608 ),
609 "postgres:///sando_code_smoke_0_9_6?host=/var/run/postgresql",
610 );
611 // Plain host/db, no query.
612 assert_eq!(
613 pg_url_with_dbname("postgres://localhost/scratch", "postgres"),
614 "postgres://localhost/scratch".replace("scratch", "postgres"),
615 );
616 // No authority, no query (loopback socket, default db path).
617 assert_eq!(
618 pg_url_with_dbname("postgres:///scratch", "postgres"),
619 "postgres:///postgres",
620 );
621 }
622
623 /// Sanity: applying MNW migrations from a *non-existent* dir errors,
624 /// rather than silently no-op'ing. Cheap pure check, no postgres needed
625 /// (the sqlx::Migrator::new constructor itself reads the dir).
626 #[tokio::test]
627 async fn run_migrator_errors_on_missing_dir() {
628 // The first thing run_migrator does is `Migrator::new(dir)`, which
629 // needs a real dir to read migration files from.
630 let res = run_migrator(
631 "postgres:///does-not-matter",
632 std::path::Path::new("/nonexistent/sando-test-migrations"),
633 )
634 .await;
635 assert!(res.is_err());
636 }
637 }
638