Skip to main content

gobby_code/db/
resolution.rs

1use std::net::ToSocketAddrs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use anyhow::{Context as _, anyhow, bail};
6use gobby_core::provisioning::{GCORE_CONFIG_FILENAME, StandaloneConfig};
7use serde::Deserialize;
8
9const GCODE_DATABASE_URL_ENV: &str = "GCODE_DATABASE_URL";
10const GOBBY_POSTGRES_DSN_ENV: &str = "GOBBY_POSTGRES_DSN";
11const GCODE_BROKER_TIMEOUT_MS_ENV: &str = "GCODE_BROKER_TIMEOUT_MS";
12const LOCAL_CLI_TOKEN_FILENAME: &str = "local_cli_token";
13const DEFAULT_BROKER_TIMEOUT: Duration = Duration::from_millis(7000);
14
15#[derive(Debug, Deserialize)]
16struct BrokerDatabaseUrlResponse {
17    database_url: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21struct BootstrapDatabase {
22    hub_backend: String,
23    database_url: Option<String>,
24}
25
26/// Return Gobby home, respecting `GOBBY_HOME` when the daemon was configured with it.
27pub fn gobby_home() -> anyhow::Result<PathBuf> {
28    gobby_core::gobby_home()
29}
30
31pub fn bootstrap_path() -> anyhow::Result<PathBuf> {
32    Ok(gobby_home()?.join("bootstrap.yaml"))
33}
34
35/// Resolve the PostgreSQL hub DSN from explicit overrides or Gobby bootstrap config.
36///
37/// gcode intentionally has no local database fallback. It asks the long-lived daemon
38/// broker first, then falls back to explicit DSN sources for daemonless operation.
39pub fn resolve_database_url() -> anyhow::Result<String> {
40    let home = gobby_home()?;
41    resolve_database_url_from_sources_with_identity_and_reachability(
42        &home,
43        |bootstrap_path| resolve_brokered_database_url_at(&home, bootstrap_path),
44        |name| std::env::var(name).ok(),
45        |url| gobby_core::postgres::connect_readonly(url).is_ok(),
46        gobby_core::provisioning::probe_postgres_hub_identity,
47    )
48}
49
50#[cfg(test)]
51fn resolve_database_url_from_sources(
52    home: &Path,
53    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
54    get_var: impl FnMut(&str) -> Option<String>,
55    database_reachable: impl FnMut(&str) -> bool,
56) -> anyhow::Result<String> {
57    resolve_database_url_from_sources_with_identity_and_reachability(
58        home,
59        broker_resolver,
60        get_var,
61        database_reachable,
62        gobby_core::provisioning::probe_postgres_hub_identity,
63    )
64}
65
66#[cfg(test)]
67fn resolve_database_url_from_sources_with_identity(
68    home: &Path,
69    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
70    get_var: impl FnMut(&str) -> Option<String>,
71    database_reachable: impl FnMut(&str) -> bool,
72    identity_probe: impl FnMut(&str) -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
73) -> anyhow::Result<String> {
74    resolve_database_url_from_sources_with_identity_and_reachability(
75        home,
76        broker_resolver,
77        get_var,
78        database_reachable,
79        identity_probe,
80    )
81}
82
83fn resolve_database_url_from_sources_with_identity_and_reachability(
84    home: &Path,
85    broker_resolver: impl Fn(&Path) -> anyhow::Result<String>,
86    get_var: impl FnMut(&str) -> Option<String>,
87    mut database_reachable: impl FnMut(&str) -> bool,
88    mut identity_probe: impl FnMut(
89        &str,
90    )
91        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
92) -> anyhow::Result<String> {
93    let path = home.join("bootstrap.yaml");
94
95    if let Some(database_url) = resolve_database_url_from_env(get_var) {
96        return Ok(database_url);
97    }
98
99    let gcore_database_url = match resolve_database_url_from_gcore_config(home) {
100        Ok(database_url) => database_url,
101        Err(error) => {
102            log::warn!("failed to read gcore config database URL: {error}");
103            None
104        }
105    };
106
107    if let Ok(database_url) = broker_resolver(&path) {
108        if let Some(database_url) = resolve_recorded_hub_database_url(
109            gcore_database_url.as_deref(),
110            &database_url,
111            &mut database_reachable,
112            &mut identity_probe,
113        )? {
114            return Ok(database_url);
115        }
116        return Ok(database_url);
117    }
118
119    if let Some(database_url) = resolve_database_url_from_bootstrap_file(&path)? {
120        if let Some(database_url) = resolve_recorded_hub_database_url(
121            gcore_database_url.as_deref(),
122            &database_url,
123            &mut database_reachable,
124            &mut identity_probe,
125        )? {
126            return Ok(database_url);
127        }
128        return Ok(database_url);
129    }
130
131    if let Some(database_url) = gcore_database_url {
132        return Ok(database_url);
133    }
134
135    bail!(
136        "missing Gobby PostgreSQL configuration. Run `gcode setup --standalone`, set {GCODE_DATABASE_URL_ENV}, or configure the Gobby daemon bootstrap."
137    )
138}
139
140fn resolve_recorded_hub_database_url(
141    gcore_database_url: Option<&str>,
142    candidate_database_url: &str,
143    database_reachable: &mut impl FnMut(&str) -> bool,
144    identity_probe: &mut impl FnMut(
145        &str,
146    )
147        -> anyhow::Result<gobby_core::provisioning::HubIdentityProbeResult>,
148) -> anyhow::Result<Option<String>> {
149    Ok(gobby_core::provisioning::resolve_recorded_hub_database_url(
150        gcore_database_url,
151        Some(candidate_database_url),
152        database_reachable,
153        identity_probe,
154    )?
155    .map(|resolution| resolution.database_url))
156}
157
158fn resolve_database_url_from_bootstrap_file(path: &Path) -> anyhow::Result<Option<String>> {
159    if !path.exists() {
160        return Ok(None);
161    }
162    let contents = std::fs::read_to_string(path)
163        .with_context(|| format!("failed to read Gobby bootstrap at {}", path.display()))?;
164    let bootstrap = parse_bootstrap_database(&contents)?;
165    resolve_database_url_from_bootstrap(&bootstrap).map(Some)
166}
167
168fn resolve_database_url_from_gcore_config(home: &Path) -> anyhow::Result<Option<String>> {
169    let Some(config) = StandaloneConfig::read_at(&home.join(GCORE_CONFIG_FILENAME))? else {
170        return Ok(None);
171    };
172    Ok(config
173        .get("databases.postgres.dsn")
174        .and_then(|value| non_empty_trimmed(Some(value.to_string()))))
175}
176
177fn resolve_database_url_from_env(
178    mut get_var: impl FnMut(&str) -> Option<String>,
179) -> Option<String> {
180    for name in [GCODE_DATABASE_URL_ENV, GOBBY_POSTGRES_DSN_ENV] {
181        if let Some(value) = non_empty_trimmed(get_var(name)) {
182            return Some(value);
183        }
184    }
185    None
186}
187
188fn parse_bootstrap_database(contents: &str) -> anyhow::Result<BootstrapDatabase> {
189    let yaml: serde_yaml::Value =
190        serde_yaml::from_str(contents).context("failed to parse bootstrap.yaml")?;
191    let Some(map) = yaml.as_mapping() else {
192        bail!("bootstrap.yaml must be a mapping");
193    };
194
195    let get_string = |name: &str| -> anyhow::Result<Option<String>> {
196        let key = serde_yaml::Value::String(name.to_string());
197        match map.get(&key) {
198            Some(value) => match value.as_str() {
199                Some(text) if !text.trim().is_empty() => Ok(Some(text.to_string())),
200                Some(_) | None => bail!("bootstrap.yaml field `{name}` must be a string"),
201            },
202            None => Ok(None),
203        }
204    };
205
206    Ok(BootstrapDatabase {
207        hub_backend: get_string("hub_backend")?
208            .context("bootstrap.yaml must include `hub_backend: postgres`")?,
209        database_url: get_string("database_url")?,
210    })
211}
212
213fn resolve_database_url_from_bootstrap(bootstrap: &BootstrapDatabase) -> anyhow::Result<String> {
214    if bootstrap.hub_backend != "postgres" {
215        bail!(
216            "gcode requires `hub_backend: postgres` in bootstrap.yaml. Current hub_backend is `{}`. Configure the Gobby PostgreSQL hub before running gcode.",
217            bootstrap.hub_backend
218        );
219    }
220
221    if let Some(database_url) = bootstrap.database_url.as_deref() {
222        return Ok(database_url.to_string());
223    }
224
225    bail!("hub_backend=postgres requires `database_url` in bootstrap.yaml")
226}
227
228fn non_empty_trimmed(value: Option<String>) -> Option<String> {
229    let trimmed = value.as_ref()?.trim();
230    if trimmed.is_empty() {
231        None
232    } else {
233        Some(trimmed.to_string())
234    }
235}
236
237fn resolve_brokered_database_url_at(
238    gobby_home: &Path,
239    bootstrap_path: &Path,
240) -> anyhow::Result<String> {
241    let token = read_local_cli_token_at(gobby_home)?;
242    let daemon_url = gobby_core::daemon_url::daemon_url_at(bootstrap_path);
243    request_broker_database_url(&daemon_url, &token)
244}
245
246fn read_local_cli_token_at(gobby_home: &Path) -> anyhow::Result<String> {
247    let path = gobby_home.join(LOCAL_CLI_TOKEN_FILENAME);
248    let token = std::fs::read_to_string(&path)
249        .with_context(|| format!("missing local CLI token at {}", path.display()))?;
250    let token = token.trim().to_string();
251    if token.is_empty() {
252        bail!("local CLI token at {} is empty", path.display());
253    }
254    Ok(token)
255}
256
257fn request_broker_database_url(daemon_url: &str, token: &str) -> anyhow::Result<String> {
258    validate_loopback_daemon_url(daemon_url)?;
259    let url = format!(
260        "{}/api/local/runtime/database-url",
261        daemon_url.trim_end_matches('/')
262    );
263    let timeout = broker_timeout();
264    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
265    let response = agent
266        .post(&url)
267        .set("X-Gobby-Local-Token", token)
268        .call()
269        .map_err(|err| {
270            anyhow!(
271                "database_url broker request failed after {}ms: {err}",
272                timeout.as_millis()
273            )
274        })?;
275    let body: BrokerDatabaseUrlResponse = response
276        .into_json()
277        .context("database_url broker response was not valid JSON")?;
278    let database_url = body.database_url.trim().to_string();
279    validate_broker_database_url(&database_url)
280}
281
282fn broker_timeout() -> Duration {
283    broker_timeout_from_env(|name| std::env::var(name).ok())
284}
285
286fn broker_timeout_from_env(env: impl Fn(&str) -> Option<String>) -> Duration {
287    let Some(raw) = env(GCODE_BROKER_TIMEOUT_MS_ENV) else {
288        return DEFAULT_BROKER_TIMEOUT;
289    };
290    match raw.trim().parse::<u64>() {
291        Ok(value) if value > 0 => Duration::from_millis(value),
292        _ => {
293            log::warn!(
294                "invalid {GCODE_BROKER_TIMEOUT_MS_ENV}={raw:?}; using default {}ms",
295                DEFAULT_BROKER_TIMEOUT.as_millis()
296            );
297            DEFAULT_BROKER_TIMEOUT
298        }
299    }
300}
301
302fn validate_loopback_daemon_url(daemon_url: &str) -> anyhow::Result<()> {
303    let url = reqwest::Url::parse(daemon_url)
304        .with_context(|| format!("database_url broker daemon URL is invalid: {daemon_url}"))?;
305    let host = url
306        .host_str()
307        .ok_or_else(|| anyhow!("database_url broker daemon URL must include a host"))?;
308    let port = url.port_or_known_default().ok_or_else(|| {
309        anyhow!("database_url broker daemon URL must include a port or known scheme")
310    })?;
311    let mut resolved = (host, port)
312        .to_socket_addrs()
313        .with_context(|| format!("resolve database_url broker daemon host `{host}`"))?
314        .peekable();
315    if resolved.peek().is_none() {
316        bail!("database_url broker daemon host `{host}` resolved no addresses");
317    }
318    if resolved.all(|addr| addr.ip().is_loopback()) {
319        Ok(())
320    } else {
321        bail!("database_url broker daemon host `{host}` must resolve only to loopback addresses");
322    }
323}
324
325fn validate_broker_database_url(database_url: &str) -> anyhow::Result<String> {
326    if database_url.is_empty() {
327        bail!("database_url broker response was empty");
328    }
329    let Some(without_scheme) = database_url
330        .strip_prefix("postgres://")
331        .or_else(|| database_url.strip_prefix("postgresql://"))
332    else {
333        bail!("database_url broker response must use postgres:// or postgresql://");
334    };
335    let Some((authority, path_and_query)) = without_scheme.split_once('/') else {
336        bail!("database_url broker response must include a database path");
337    };
338    let host_port = authority.rsplit('@').next().unwrap_or_default();
339    let has_host = if let Some(rest) = host_port.strip_prefix('[') {
340        rest.split_once(']')
341            .is_some_and(|(host, _)| !host.is_empty())
342    } else {
343        !host_port.split(':').next().unwrap_or_default().is_empty()
344    };
345    if !has_host {
346        bail!("database_url broker response must include a host");
347    }
348    let database_path = path_and_query.split('?').next().unwrap_or_default();
349    if database_path.is_empty() {
350        bail!("database_url broker response must include a database path");
351    }
352    Ok(database_url.to_string())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::io::{Read as _, Write as _};
359    use std::net::TcpListener;
360    use std::thread;
361
362    fn bootstrap(hub_backend: &str, database_url: Option<&str>) -> BootstrapDatabase {
363        BootstrapDatabase {
364            hub_backend: hub_backend.to_string(),
365            database_url: database_url.map(str::to_string),
366        }
367    }
368
369    #[test]
370    fn database_url_env_prefers_gcode_specific_var() {
371        let resolved = resolve_database_url_from_env(|name| match name {
372            GCODE_DATABASE_URL_ENV => Some(" postgresql://env/db ".to_string()),
373            GOBBY_POSTGRES_DSN_ENV => Some("postgresql://gobby/db".to_string()),
374            _ => None,
375        });
376
377        assert_eq!(resolved.as_deref(), Some("postgresql://env/db"));
378    }
379
380    #[test]
381    fn database_url_env_falls_back_to_gobby_postgres_dsn() {
382        let resolved = resolve_database_url_from_env(|name| match name {
383            GOBBY_POSTGRES_DSN_ENV => Some(" postgresql://gobby/db ".to_string()),
384            _ => None,
385        });
386
387        assert_eq!(resolved.as_deref(), Some("postgresql://gobby/db"));
388    }
389
390    #[test]
391    fn database_url_env_ignores_empty_values() {
392        let resolved = resolve_database_url_from_env(|name| match name {
393            GCODE_DATABASE_URL_ENV => Some("  ".to_string()),
394            GOBBY_POSTGRES_DSN_ENV => Some("\n\t".to_string()),
395            _ => None,
396        });
397
398        assert_eq!(resolved, None);
399    }
400
401    #[test]
402    fn database_url_sources_prefer_env_over_daemon_broker() {
403        let home = tempfile::tempdir().expect("temp home");
404
405        let resolved = resolve_database_url_from_sources(
406            home.path(),
407            |_| Ok("postgresql://broker/db".to_string()),
408            |name| match name {
409                GCODE_DATABASE_URL_ENV => Some("postgresql://env/db".to_string()),
410                _ => None,
411            },
412            |_| true,
413        )
414        .expect("resolve database url");
415
416        assert_eq!(resolved, "postgresql://env/db");
417    }
418
419    #[test]
420    fn database_url_sources_use_daemon_broker_after_env() {
421        let home = tempfile::tempdir().expect("temp home");
422
423        let resolved = resolve_database_url_from_sources(
424            home.path(),
425            |_| Ok("postgresql://broker/db".to_string()),
426            |_| None,
427            |_| true,
428        )
429        .expect("resolve database url");
430
431        assert_eq!(resolved, "postgresql://broker/db");
432    }
433
434    #[test]
435    fn database_url_sources_fall_back_to_bootstrap_inline_when_daemon_is_unavailable() {
436        let home = tempfile::tempdir().expect("temp home");
437        std::fs::write(
438            home.path().join("bootstrap.yaml"),
439            "hub_backend: postgres\ndatabase_url: postgresql://inline/db\n",
440        )
441        .expect("write bootstrap");
442
443        let resolved = resolve_database_url_from_sources(
444            home.path(),
445            |_| bail!("daemon unavailable"),
446            |_| None,
447            |_| true,
448        )
449        .expect("resolve database url");
450
451        assert_eq!(resolved, "postgresql://inline/db");
452    }
453
454    #[test]
455    fn database_url_sources_use_gcore_after_daemon_and_bootstrap() {
456        let home = tempfile::tempdir().expect("temp home");
457        std::fs::write(
458            home.path().join(GCORE_CONFIG_FILENAME),
459            "databases.postgres.dsn: postgresql://gcore/db\n",
460        )
461        .expect("write gcore config");
462
463        let resolved = resolve_database_url_from_sources(
464            home.path(),
465            |_| bail!("daemon unavailable"),
466            |_| None,
467            |_| true,
468        )
469        .expect("resolve database url");
470
471        assert_eq!(resolved, "postgresql://gcore/db");
472    }
473
474    #[test]
475    fn adopted_hub_resolves_without_conflict() {
476        let home = tempfile::tempdir().expect("temp home");
477        std::fs::write(
478            home.path().join(GCORE_CONFIG_FILENAME),
479            "databases.postgres.dsn: postgresql://adopted/gobby\n",
480        )
481        .expect("write gcore config");
482
483        let resolved = resolve_database_url_from_sources_with_identity(
484            home.path(),
485            |_| Ok("postgresql://adopted/gobby".to_string()),
486            |_| None,
487            |_| true,
488            |_| {
489                Ok(gobby_core::provisioning::HubIdentityProbeResult::Known(
490                    gobby_core::provisioning::HubIdentity {
491                        system_identifier: "cluster-a".to_string(),
492                        database_name: "gobby".to_string(),
493                    },
494                ))
495            },
496        )
497        .expect("resolve adopted hub");
498
499        assert_eq!(resolved, "postgresql://adopted/gobby");
500    }
501
502    #[test]
503    fn postgres_bootstrap_accepts_inline_url() {
504        let resolved = resolve_database_url_from_bootstrap(&bootstrap(
505            "postgres",
506            Some("postgresql://inline/db"),
507        ))
508        .expect("resolve inline url");
509
510        assert_eq!(resolved, "postgresql://inline/db");
511    }
512
513    #[test]
514    fn non_postgres_bootstrap_fails_clearly() {
515        let err = resolve_database_url_from_bootstrap(&bootstrap("local-file", None))
516            .expect_err("non-postgres backend must fail");
517
518        let message = err.to_string();
519        assert!(message.contains("hub_backend: postgres"));
520        assert!(message.contains("local-file"));
521    }
522
523    #[test]
524    fn missing_hub_backend_fails_clearly() {
525        let err = parse_bootstrap_database("database_url: postgresql://inline/db\n")
526            .expect_err("missing hub_backend must fail");
527
528        assert!(err.to_string().contains("hub_backend: postgres"));
529    }
530
531    #[test]
532    fn missing_postgres_dsn_fails_clearly() {
533        let err = resolve_database_url_from_bootstrap(&bootstrap("postgres", None))
534            .expect_err("missing dsn must fail");
535
536        assert!(err.to_string().contains("database_url"));
537    }
538
539    #[test]
540    fn parse_bootstrap_database_reads_postgres_fields() {
541        let parsed = parse_bootstrap_database(
542            "hub_backend: postgres\n\
543             database_url: postgresql://inline/db\n",
544        )
545        .expect("parse bootstrap");
546
547        assert_eq!(parsed.hub_backend, "postgres");
548        assert_eq!(
549            parsed.database_url.as_deref(),
550            Some("postgresql://inline/db")
551        );
552    }
553
554    #[test]
555    fn broker_request_returns_database_url_and_sends_local_token() {
556        let (daemon_url, request) = spawn_http_response(http_response(
557            "200 OK",
558            r#"{"database_url":"postgresql://broker/db"}"#,
559        ));
560
561        let resolved =
562            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
563        let request = request.join().expect("read request");
564
565        assert_eq!(resolved, "postgresql://broker/db");
566        assert!(request.starts_with("POST /api/local/runtime/database-url HTTP/1.1"));
567        assert!(
568            request
569                .to_ascii_lowercase()
570                .contains("x-gobby-local-token: token-123")
571        );
572    }
573
574    #[test]
575    fn broker_request_rejects_non_loopback_daemon_url_before_sending_local_token() {
576        let err = request_broker_database_url("http://192.0.2.1:60887", "token-123")
577            .expect_err("non-loopback daemon URL must fail");
578
579        assert!(
580            err.to_string()
581                .contains("must resolve only to loopback addresses")
582        );
583    }
584
585    #[test]
586    fn broker_request_allows_cold_daemon_latency() {
587        let (daemon_url, request) = spawn_http_response_after(
588            http_response("200 OK", r#"{"database_url":"postgresql://broker/db"}"#),
589            Duration::from_millis(1100),
590        );
591
592        let resolved =
593            request_broker_database_url(&daemon_url, "token-123").expect("broker resolves");
594        let _ = request.join().expect("read request");
595
596        assert_eq!(resolved, "postgresql://broker/db");
597    }
598
599    #[test]
600    fn broker_timeout_defaults_to_seven_seconds() {
601        let timeout = broker_timeout_from_env(|_| None);
602
603        assert_eq!(timeout, Duration::from_millis(7000));
604    }
605
606    #[test]
607    fn broker_timeout_reads_positive_env_value() {
608        let timeout = broker_timeout_from_env(|name| {
609            (name == GCODE_BROKER_TIMEOUT_MS_ENV).then(|| "1250".to_string())
610        });
611
612        assert_eq!(timeout, Duration::from_millis(1250));
613    }
614
615    #[test]
616    fn broker_timeout_ignores_invalid_env_value() {
617        let timeout = broker_timeout_from_env(|name| {
618            (name == GCODE_BROKER_TIMEOUT_MS_ENV).then(|| "0".to_string())
619        });
620
621        assert_eq!(timeout, DEFAULT_BROKER_TIMEOUT);
622    }
623
624    #[test]
625    fn broker_missing_token_fails() {
626        let home = tempfile::tempdir().expect("temp home");
627        let bootstrap_path = write_bootstrap(home.path(), 60887);
628
629        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
630            .expect_err("missing token must fail");
631
632        assert!(err.to_string().contains("missing local CLI token"));
633    }
634
635    #[test]
636    fn broker_daemon_down_fails() {
637        let home = tempfile::tempdir().expect("temp home");
638        std::fs::write(home.path().join(LOCAL_CLI_TOKEN_FILENAME), "token\n").expect("write token");
639        let bootstrap_path = write_bootstrap(home.path(), 9);
640
641        let err = resolve_brokered_database_url_at(home.path(), &bootstrap_path)
642            .expect_err("daemon down must fail");
643
644        assert!(
645            err.to_string()
646                .contains("database_url broker request failed")
647        );
648    }
649
650    #[test]
651    fn broker_auth_failure_fails() {
652        let (daemon_url, request) = spawn_http_response(http_response(
653            "401 Unauthorized",
654            r#"{"error":"bad token"}"#,
655        ));
656
657        let err = request_broker_database_url(&daemon_url, "bad-token")
658            .expect_err("auth failure must fail");
659        let _ = request.join().expect("read request");
660
661        assert!(
662            err.to_string()
663                .contains("database_url broker request failed")
664        );
665    }
666
667    #[test]
668    fn broker_non_success_status_fails() {
669        let (daemon_url, request) = spawn_http_response(http_response(
670            "503 Service Unavailable",
671            r#"{"error":"unavailable"}"#,
672        ));
673
674        let err = request_broker_database_url(&daemon_url, "token")
675            .expect_err("non-success status must fail");
676        let _ = request.join().expect("read request");
677
678        assert!(
679            err.to_string()
680                .contains("database_url broker request failed")
681        );
682    }
683
684    #[test]
685    fn broker_invalid_json_fails() {
686        let (daemon_url, request) = spawn_http_response(http_response("200 OK", "not json"));
687
688        let err =
689            request_broker_database_url(&daemon_url, "token").expect_err("invalid JSON must fail");
690        let _ = request.join().expect("read request");
691
692        assert!(
693            err.to_string()
694                .contains("database_url broker response was not valid JSON")
695        );
696    }
697
698    #[test]
699    fn broker_empty_database_url_fails() {
700        let (daemon_url, request) =
701            spawn_http_response(http_response("200 OK", r#"{"database_url":"  "}"#));
702
703        let err =
704            request_broker_database_url(&daemon_url, "token").expect_err("empty DSN must fail");
705        let _ = request.join().expect("read request");
706
707        assert!(
708            err.to_string()
709                .contains("database_url broker response was empty")
710        );
711    }
712
713    #[test]
714    fn broker_invalid_database_url_scheme_fails() {
715        let err = validate_broker_database_url("http://broker/db")
716            .expect_err("non-postgres scheme must fail");
717
718        assert!(
719            err.to_string()
720                .contains("must use postgres:// or postgresql://")
721        );
722    }
723
724    #[test]
725    fn broker_missing_database_url_host_fails() {
726        let err =
727            validate_broker_database_url("postgresql:///db").expect_err("missing host must fail");
728
729        assert!(
730            err.to_string()
731                .contains("database_url broker response must include a host")
732        );
733    }
734
735    #[test]
736    fn broker_missing_database_url_path_fails() {
737        let err = validate_broker_database_url("postgresql://broker/")
738            .expect_err("missing path must fail");
739
740        assert!(
741            err.to_string()
742                .contains("database_url broker response must include a database path")
743        );
744    }
745
746    fn write_bootstrap(home: &Path, daemon_port: u16) -> PathBuf {
747        let path = home.join("bootstrap.yaml");
748        std::fs::write(
749            &path,
750            format!("hub_backend: postgres\ndaemon_port: {daemon_port}\nbind_host: 127.0.0.1\n"),
751        )
752        .expect("write bootstrap");
753        path
754    }
755
756    fn http_response(status: &str, body: &str) -> String {
757        format!(
758            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
759            body.len()
760        )
761    }
762
763    fn spawn_http_response(response: String) -> (String, thread::JoinHandle<String>) {
764        spawn_http_response_after(response, Duration::ZERO)
765    }
766
767    fn spawn_http_response_after(
768        response: String,
769        delay: Duration,
770    ) -> (String, thread::JoinHandle<String>) {
771        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
772        let addr = listener.local_addr().expect("local addr");
773        let handle = thread::spawn(move || {
774            let (mut stream, _) = listener.accept().expect("accept request");
775            let mut request = Vec::new();
776            let mut buffer = [0_u8; 1024];
777            loop {
778                let read = stream.read(&mut buffer).expect("read request");
779                if read == 0 {
780                    break;
781                }
782                request.extend_from_slice(&buffer[..read]);
783                if request.windows(4).any(|window| window == b"\r\n\r\n") {
784                    break;
785                }
786            }
787            thread::sleep(delay);
788            stream
789                .write_all(response.as_bytes())
790                .expect("write response");
791            String::from_utf8_lossy(&request).into_owned()
792        });
793        (format!("http://{addr}"), handle)
794    }
795}