Skip to main content

dynamic_config_server/client/
mod.rs

1//! Reading configuration *from* a config server.
2//!
3//! The other half of this crate, behind the `client` feature: a
4//! [`RemoteSource`] that fetches `GET /{application}/{profile}` and hands the
5//! document to the engine, exactly as an etcd or a Vault source does. The two
6//! halves live in one crate so that they are tested against each other —
7//! every test in `tests/client.rs` drives this against the real router rather
8//! than against a fixture of what the router is believed to return.
9//!
10//! ```no_run
11//! use std::time::Duration;
12//! use dynamic_config_server::client::ConfigServer;
13//!
14//! # fn main() -> Result<(), dynamic_config::Error> {
15//! let source = ConfigServer::new("https://config.internal", "billing", "prod")
16//!     .with_token(std::env::var("CONFIG_TOKEN").unwrap_or_default())
17//!     .with_timeout(Duration::from_secs(5));
18//! # let _ = source;
19//! # Ok(())
20//! # }
21//! ```
22//!
23//! ## Watching
24//!
25//! It subscribes. `GET /{application}/{profile}/stream` carries a generation
26//! number, and [`ConfigServer::watch`] follows it: connect, read events,
27//! re-fetch the document when the number moves, reconnect with the
28//! `Last-Event-ID` the server left off at. The reconnect is a comparison
29//! rather than a replay — a generation subsumes every one before it — so
30//! there is no window in which a change can be missed by being reconnected
31//! past.
32//!
33//! ## What it does not do
34//!
35//! **It does not verify provenance.** The document arrives as JSON with no
36//! signature, so a client trusts the server exactly as far as TLS and the
37//! bearer token take it. A deployment that needs more should read from the
38//! store the server reads from.
39
40mod http;
41
42use std::sync::Arc;
43use std::time::Duration;
44
45use dynamic_config::{Error, Fetched, Format, Pace, RemoteSource, WatchCapability, Watching};
46use dynamic_config_store_core::attempts::Attempts;
47use dynamic_config_store_core::tls::TlsConfig;
48use dynamic_config_store_core::{guarded, redacted, LoneAuthority};
49
50use http::{Budget, Connection, Endpoint, Events, Get};
51
52/// How much of a response body is read before it is refused.
53///
54/// A configuration document that does not fit in a megabyte is not a
55/// configuration document, and a client that trusts a server to send
56/// something finite is a client that can be made to allocate until it dies.
57const MOST_BYTES: usize = 1024 * 1024;
58
59/// The default deadline for one fetch — connect, handshake, request and body.
60const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
61
62/// A [`RemoteSource`] reading one application-and-profile from a config
63/// server.
64///
65/// The credential is a bearer token, scoped by the server to the applications
66/// it may read; TLS with a private authority and a client certificate is
67/// [`TlsConfig`], the same type every store crate in this workspace takes.
68pub struct ConfigServer {
69    url: String,
70    application: String,
71    profile: String,
72    token: Option<String>,
73    token_file: Option<std::path::PathBuf>,
74    tls: TlsConfig,
75    timeout: Duration,
76    /// Built once from `tls`, on the first fetch: assembling a rustls
77    /// configuration reads files, and a fetch path is not where that belongs.
78    client: std::sync::OnceLock<Arc<rustls::ClientConfig>>,
79    described: String,
80    /// Where a watch reports an attempt that came back with nothing.
81    ///
82    /// Nobody, unless [`reporting_to`](Self::reporting_to) says otherwise —
83    /// the same door the eight store crates carry, and for the same reason:
84    /// a watch swallows transport failures by design, so without this the
85    /// only thing that knew the server had been unreachable for an hour was
86    /// the loop, and `status().reachable()` went on answering `true`.
87    attempts: Attempts,
88}
89
90impl ConfigServer {
91    /// A source reading `{url}/{application}/{profile}`.
92    ///
93    /// `url` may carry a path prefix — `https://config.internal/config` — for
94    /// a server mounted behind one. A userinfo component is refused rather
95    /// than dropped: this server's credential is a bearer token, and a
96    /// password in a url is a password in every log that url reaches.
97    #[must_use]
98    pub fn new(
99        url: impl Into<String>,
100        application: impl Into<String>,
101        profile: impl Into<String>,
102    ) -> Self {
103        let (url, application, profile) = (url.into(), application.into(), profile.into());
104
105        // Redacted as the description is built, rather than where each
106        // message is written: this string is quoted into every error this
107        // source raises and is what `describe()` returns — and one of those
108        // errors is the *refusal* of a `user:password@` authority. Printing
109        // the password while saying it is refused would be a leak with a
110        // note attached.
111        let described = format!(
112            "config server {} {application}/{profile}",
113            redacted(&url, LoneAuthority::Username)
114        );
115
116        Self {
117            // Parsing is deferred to the first fetch so that `new` cannot
118            // fail: a source that refuses to be *built* is awkward to place
119            // in a builder chain, and the url is checked before it is used.
120            url,
121            application,
122            profile,
123            token: None,
124            token_file: None,
125            tls: TlsConfig::new(),
126            timeout: DEFAULT_TIMEOUT,
127            client: std::sync::OnceLock::new(),
128            described,
129            attempts: Attempts::default(),
130        }
131    }
132
133    /// Report failed attempts to `sink`, so an outage is visible.
134    ///
135    /// A watch swallows transport failures on purpose — outliving one is
136    /// what a watch is for — and the cost of that is a store that has been
137    /// unreachable for an hour while `status().reachable()` says otherwise.
138    /// This is the door the eight store crates carry, and the same
139    /// discipline: take the sink where the watch is wired, once, because a
140    /// sink captures the generation of the source installed at that moment
141    /// and that is what fences a winding-down loop's failures away from its
142    /// replacement.
143    ///
144    /// **A failure moves the failure streak and nothing else.** The fetch
145    /// count and the clock are left alone, so a dashboard keeps ageing
146    /// `last_fetch` while `up` goes to zero — the pair an alert wants. It
147    /// changes nothing about what [`watch`](Self::watch) returns.
148    #[must_use]
149    pub fn reporting_to(mut self, sink: dynamic_config::RemoteSink) -> Self {
150        self.attempts = Attempts::to(sink);
151        self
152    }
153
154    /// The bearer token this server issued for these applications.
155    ///
156    /// Without one the server answers `401` unless it was started with
157    /// anonymous access explicitly enabled.
158    #[must_use]
159    pub fn with_token(mut self, token: impl Into<String>) -> Self {
160        self.token = Some(token.into());
161        self
162    }
163
164    /// The bearer token read from a file, **re-read at every fetch** —
165    /// for credentials something else rotates underneath this client,
166    /// first among them a pod's projected service-account token (the
167    /// server's `[kubernetes]` auth reviews exactly that). Wins over
168    /// [`with_token`](Self::with_token) when both are set.
169    #[must_use]
170    pub fn with_token_file(mut self, path: impl Into<std::path::PathBuf>) -> Self {
171        self.token_file = Some(path.into());
172        self
173    }
174
175    /// A private certificate authority, a client certificate, or both.
176    ///
177    /// The same [`TlsConfig`] the store crates take, so a deployment spells
178    /// its trust once and uses it everywhere.
179    #[must_use]
180    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
181        self.tls = tls;
182        self
183    }
184
185    /// The deadline for one fetch: connect, TLS handshake, request and body.
186    ///
187    /// Ten seconds by default. A fetch that hangs is a reload that never
188    /// happens, and the loop above has no other way to notice.
189    #[must_use]
190    pub fn with_timeout(mut self, timeout: Duration) -> Self {
191        self.timeout = timeout;
192        self
193    }
194
195    /// The rustls configuration, built once.
196    ///
197    /// Building it reads files, so it is done on the first fetch and kept —
198    /// not per fetch, and not at construction, where it would make `new`
199    /// fallible for a source that may never be used.
200    fn tls_client(&self, secure: bool) -> Result<Option<&Arc<rustls::ClientConfig>>, Error> {
201        if !secure {
202            return Ok(None);
203        }
204
205        if let Some(built) = self.client.get() {
206            return Ok(Some(built));
207        }
208
209        let built = self.build_tls_client()?;
210
211        Ok(Some(self.client.get_or_init(|| built)))
212    }
213
214    fn build_tls_client(&self) -> Result<Arc<rustls::ClientConfig>, Error> {
215        use rustls::pki_types::pem::PemObject as _;
216        use rustls::pki_types::{CertificateDer, PrivateKeyDer};
217
218        let mut roots = rustls::RootCertStore::empty();
219
220        // The platform store first, then the caller's authority on top: a
221        // private CA is one *more* certificate to trust, which is the whole
222        // reason this crate offers no way to turn verification off.
223        for certificate in rustls_native_certs::load_native_certs().certs {
224            let _ = roots.add(certificate);
225        }
226
227        if let Some(pem) = self.tls.ca_certificate_pem(&self.described)? {
228            let mut added = 0;
229
230            for certificate in CertificateDer::pem_slice_iter(&pem) {
231                let certificate = certificate.map_err(|_| {
232                    Error::remote(format!(
233                        "{}: the certificate authority is not readable as PEM",
234                        self.described
235                    ))
236                })?;
237
238                roots
239                    .add(certificate)
240                    .map_err(|error| Error::remote(format!("{}: {error}", self.described)))?;
241                added += 1;
242            }
243
244            if added == 0 {
245                return Err(Error::remote(format!(
246                    "{}: the certificate authority holds no certificate",
247                    self.described
248                )));
249            }
250        }
251
252        let builder = rustls::ClientConfig::builder().with_root_certificates(roots);
253
254        let Some((certificate, key)) = self.tls.client_certificate_pem(&self.described)? else {
255            return Ok(Arc::new(builder.with_no_client_auth()));
256        };
257
258        let chain = CertificateDer::pem_slice_iter(&certificate)
259            .collect::<Result<Vec<_>, _>>()
260            .map_err(|_| {
261                Error::remote(format!(
262                    "{}: the client certificate is not readable as PEM",
263                    self.described
264                ))
265            })?;
266
267        // The key's own parse error is deliberately dropped: the one thing
268        // such an error has to say is the line it choked on, and in a key
269        // file that line is key material.
270        let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| {
271            Error::remote(format!(
272                "{}: the client private key is not readable as PEM",
273                self.described
274            ))
275        })?;
276
277        builder
278            .with_client_auth_cert(chain, key)
279            .map(Arc::new)
280            .map_err(|error| Error::remote(format!("{}: {error}", self.described)))
281    }
282
283    /// One fetch, on the current thread's runtime.
284    async fn read(&self) -> Result<Fetched, Error> {
285        let endpoint = Endpoint::parse(&self.url, &self.described)?;
286        let path = endpoint.path(&format!("/{}/{}", self.application, self.profile));
287
288        // One budget for the whole attempt, started here: the deadline
289        // `with_timeout` documents is for a fetch, and a fetch is the
290        // connect, the handshake, the request and the body together.
291        let budget = Budget::starting(self.timeout);
292
293        let secure = endpoint.secure;
294        let mut connection =
295            Connection::open(&endpoint, self.tls_client(secure)?, budget, &self.described).await?;
296
297        // The file wins, and is read per fetch: a projected token that
298        // rotated between two fetches must present its NEW self. One
299        // reader, shared with the watch — two copies of "which credential
300        // do we present" is one more than a credential should have.
301        let bearer = self.bearer()?;
302
303        let response = connection
304            .get(
305                &endpoint,
306                Get {
307                    path: &path,
308                    token: bearer.as_deref(),
309                    accept: "application/json",
310                    resume: None,
311                },
312                budget,
313                &self.described,
314            )
315            .await?;
316
317        if !response.status().is_success() {
318            return Err(http::refused(response.status(), &self.described));
319        }
320
321        let body = http::body(response, MOST_BYTES, budget, &self.described).await?;
322        let text = String::from_utf8(body)
323            .map_err(|_| Error::remote(format!("{}: the document is not UTF-8", self.described)))?;
324
325        // The server answers `{application, profile, generation, config}`;
326        // the engine wants the document, which is `config`. Reaching for it
327        // by name rather than deserializing the envelope keeps this working
328        // when the envelope grows a field.
329        let document = extract(&text, &self.described)?;
330
331        Ok(Fetched::new(document, Format::Json))
332    }
333}
334
335/// The `config` member of the server's envelope, re-rendered.
336fn extract(text: &str, described: &str) -> Result<String, Error> {
337    let envelope: serde_json::Value = serde_json::from_str(text)
338        .map_err(|_| Error::remote(format!("{described}: the answer is not JSON")))?;
339
340    let document = envelope.get("config").ok_or_else(|| {
341        Error::remote(format!(
342            "{described}: the answer carries no `config` member; is this a \
343             config server?"
344        ))
345    })?;
346
347    serde_json::to_string(document)
348        .map_err(|_| Error::remote(format!("{described}: the document will not re-render")))
349}
350
351impl ConfigServer {
352    /// How long a stream may be silent before it is treated as dead.
353    ///
354    /// The server sends a comment every fifteen seconds precisely so that
355    /// silence means something; three of those is a connection a proxy has
356    /// dropped without telling either end.
357    const IDLE: Duration = Duration::from_secs(50);
358
359    /// Follows the change stream, fetching whenever the generation moves.
360    ///
361    /// Blocks until `watching` is stopped, so it belongs on a thread of its
362    /// own. `interval` is the reconnect pace rather than a poll: the stream
363    /// pushes, and this is how long to wait before trying again when it
364    /// ends. The waits are spread across a fleet and grow after a failure,
365    /// so a server coming back up is not met by every pod at once.
366    ///
367    /// Each document is delivered only when it differs from the last one:
368    /// a generation moves for every install, and an install that changed
369    /// nothing this caller can see should wake nothing.
370    ///
371    /// # Errors
372    ///
373    /// If `on_change` refuses a document. A connection failing is not an
374    /// error — reconnecting through an outage is what this is for.
375    pub fn watch<F>(
376        &self,
377        watching: &Watching,
378        interval: Duration,
379        mut on_change: F,
380    ) -> Result<(), Error>
381    where
382        F: FnMut(Fetched) -> Result<(), Error>,
383    {
384        // One runtime for the whole watch, unlike `fetch`'s per-call one: a
385        // watch is a long-lived thing by definition, so the argument that
386        // makes a per-call runtime free does not apply to it.
387        let runtime = tokio::runtime::Builder::new_current_thread()
388            .enable_all()
389            .build()
390            .map_err(|error| {
391                Error::remote(format!(
392                    "{}: no runtime for the watch: {error}",
393                    self.described
394                ))
395            })?;
396
397        // Settled once, before the loop, because neither can come right by
398        // being retried: a URL this crate cannot parse and a TLS
399        // configuration it cannot build are the caller's to fix, and a loop
400        // that swallowed them reconnected forever, delivered nothing and
401        // said nothing. The eight stores validate what is deterministic up
402        // front for the same reason.
403        let endpoint = Endpoint::parse(&self.url, &self.described)?;
404        self.tls_client(endpoint.secure)?;
405
406        runtime.block_on(async {
407            let mut pace = Pace::new(interval);
408            let mut resume: Option<String> = None;
409            let mut last: Option<Fetched> = None;
410
411            while watching.keep_going() {
412                match self
413                    .subscribed(watching, &mut resume, &mut last, &mut on_change)
414                    .await
415                {
416                    Ok(()) => pace.succeeded(),
417                    // The caller refusing a document is the one failure this
418                    // loop does not own: it is a decision, not an outage.
419                    Err(Ended::Refused(error)) => return Err(error),
420                    // Everything else is swallowed on purpose, credentials
421                    // included: a token file rotating between two
422                    // connections looks exactly like a token that is wrong,
423                    // and a watch that stopped on the first would be a pod
424                    // that never recovered from a routine rotation.
425                    Err(Ended::Disconnected) => pace.failed(),
426                }
427
428                sleep_while(watching, pace.next_wait()).await;
429            }
430
431            Ok(())
432        })
433    }
434
435    /// One connection's worth of stream, from subscribe to close.
436    async fn subscribed<F>(
437        &self,
438        watching: &Watching,
439        resume: &mut Option<String>,
440        last: &mut Option<Fetched>,
441        on_change: &mut F,
442    ) -> Result<(), Ended>
443    where
444        F: FnMut(Fetched) -> Result<(), Error>,
445    {
446        let endpoint = Endpoint::parse(&self.url, &self.described)
447            .map_err(|error| self.disconnected(&error))?;
448        let path = endpoint.path(&format!("/{}/{}/stream", self.application, self.profile));
449
450        // The budget covers getting the stream open — connect, handshake,
451        // request — and stops there. A deadline on the stream itself would
452        // be a deadline on the configuration not changing.
453        let budget = Budget::starting(self.timeout);
454        let secure = endpoint.secure;
455        let tls = self
456            .tls_client(secure)
457            .map_err(|error| self.disconnected(&error))?;
458        let mut connection = Connection::open(&endpoint, tls, budget, &self.described)
459            .await
460            .map_err(|error| self.disconnected(&error))?;
461
462        let bearer = self.bearer().map_err(|error| self.disconnected(&error))?;
463        let response = connection
464            .get(
465                &endpoint,
466                Get {
467                    path: &path,
468                    token: bearer.as_deref(),
469                    accept: "text/event-stream",
470                    resume: resume.as_deref(),
471                },
472                budget,
473                &self.described,
474            )
475            .await
476            .map_err(|error| self.disconnected(&error))?;
477
478        if !response.status().is_success() {
479            let status = response.status();
480            let refusal = http::refused(status, &self.described);
481
482            // **A 404 is an answer, not an outage.** The stream path is
483            // absent when a deployment sets `max_stream_connections = 0`,
484            // and when a URL names a prefix this server does not mount —
485            // neither comes right by reconnecting, and a loop that retried
486            // them forever was a watch that delivered nothing and said
487            // nothing. Everything else is waited out, credentials included:
488            // a token file rotating between two connections looks exactly
489            // like a token that is wrong.
490            if status == 404 {
491                return Err(Ended::Refused(refusal));
492            }
493
494            return Err(self.disconnected(&refusal));
495        }
496
497        let mut events = Events::new(response);
498
499        // Whether the *first* event of this connection is the server saying
500        // where the document stands rather than that it moved. It is,
501        // exactly when this subscription sent no `Last-Event-ID`.
502        let mut opening = resume.is_none();
503
504        while watching.keep_going() {
505            let next = events
506                .next(watching, Self::IDLE, &self.described)
507                .await
508                .map_err(|error| self.disconnected(&error))?;
509
510            let Some(event) = next else {
511                // The server closed it. Ordinary — a rolling restart does
512                // exactly this — and the loop above reconnects.
513                return Ok(());
514            };
515
516            // A keep-alive says the connection is there and nothing else.
517            // Round the loop rather than through the fetch: re-reading the
518            // whole document every fifteen seconds of quiet is the poll this
519            // client exists to replace, and coming back here is also what
520            // notices a watch that has been stopped.
521            if !event.carried {
522                continue;
523            }
524
525            // The event says *something landed*; the document is fetched
526            // from the endpoint that serves documents. Reading the number
527            // out of the payload is not needed for that and is not done:
528            // an install is an install.
529            let _ = event.data;
530
531            // **The opening event is not a change.** A first subscription
532            // sends no `Last-Event-ID`, so the server opens with where the
533            // document stands — which is the current value, and
534            // "the current value is not delivered at startup" is the
535            // contract all nine sources keep. Its id is still worth having:
536            // a reconnect resumes from it.
537            if opening {
538                opening = false;
539                *resume = event.id.or_else(|| resume.take());
540
541                continue;
542            }
543
544            let fetched = self
545                .read()
546                .await
547                .map_err(|error| self.disconnected(&error))?;
548
549            if last.as_ref() != Some(&fetched) {
550                *last = Some(fetched.clone());
551
552                // Through `guarded`, as every other store delivers: a
553                // callback that panics ends the watch with an error rather
554                // than unwinding through this loop and killing the caller's
555                // thread with the `RemoteWatch` handle still looking alive.
556                guarded(on_change, fetched, &self.described).map_err(Ended::Refused)?;
557            }
558
559            // **Advanced last, and only on the way out.** Moving it before
560            // the fetch meant a fetch that failed still counted: the
561            // reconnect carried a `Last-Event-ID` for a generation this
562            // client never read, the server saw nothing newer, and the
563            // change was lost until the next install — the one window the
564            // module documentation says cannot exist.
565            if let Some(id) = event.id {
566                *resume = Some(id);
567            }
568        }
569
570        Ok(())
571    }
572
573    /// An attempt that came back with nothing, reported and then forgotten.
574    ///
575    /// Reporting happens here rather than at each call site so that a
576    /// failure branch added later cannot be the one that forgets — the
577    /// same shape the eight store crates use.
578    fn disconnected(&self, error: &Error) -> Ended {
579        self.attempts.failed(error);
580
581        Ended::Disconnected
582    }
583
584    /// The bearer token to present, file first.
585    fn bearer(&self) -> Result<Option<String>, Error> {
586        match &self.token_file {
587            Some(file) => std::fs::read_to_string(file)
588                .map(|token| Some(token.trim().to_owned()))
589                .map_err(|error| {
590                    Error::auth(format!(
591                        "{}: reading the bearer token file: {error}",
592                        self.described
593                    ))
594                }),
595            None => Ok(self.token.clone()),
596        }
597    }
598}
599
600/// Why one connection's worth of stream ended.
601///
602/// The distinction the loop above acts on, and the only one it needs: a
603/// connection that failed is waited out and tried again, and a caller that
604/// refused a document has made a decision the loop has no business
605/// overriding.
606enum Ended {
607    /// The connection failed, or the server refused the subscription. The
608    /// error is not carried past here: the loop waits and tries again, and a
609    /// message per reconnect through an outage is a log nobody can read. It
610    /// *is* reported first — see `ConfigServer::disconnected`.
611    Disconnected,
612    Refused(Error),
613}
614
615/// Sleeps for `total`, waking early once the watch is stopped.
616async fn sleep_while(watching: &Watching, total: Duration) {
617    const SLICE: Duration = Duration::from_millis(250);
618
619    let mut left = total;
620
621    while left > Duration::ZERO && watching.keep_going() {
622        let slice = left.min(SLICE);
623
624        tokio::time::sleep(slice).await;
625        left -= slice;
626    }
627}
628
629impl RemoteSource for ConfigServer {
630    fn fetch(&self) -> Result<Fetched, Error> {
631        // A blocking `fetch` on a client built from an async stack: one
632        // runtime, current-thread, for this call only. A source is fetched
633        // when a caller asks, minutes or hours apart, so the cost of starting
634        // one is not on any path that matters — and owning a long-lived
635        // runtime here would put a second one inside applications that
636        // already have theirs.
637        let runtime = tokio::runtime::Builder::new_current_thread()
638            .enable_all()
639            .build()
640            .map_err(|error| {
641                Error::remote(format!(
642                    "{}: no runtime for the fetch: {error}",
643                    self.described
644                ))
645            })?;
646
647        runtime.block_on(self.read())
648    }
649
650    fn describe(&self) -> String {
651        self.described.clone()
652    }
653
654    /// Native: the server pushes a generation down a `text/event-stream`.
655    fn watch_capability(&self) -> WatchCapability {
656        WatchCapability::Native
657    }
658
659    fn watch(
660        &self,
661        watching: &Watching,
662        interval: Duration,
663        on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
664    ) -> Result<(), Error> {
665        ConfigServer::watch(self, watching, interval, on_change)
666    }
667}
668
669impl std::fmt::Debug for ConfigServer {
670    /// Shape only. The token is the credential and never prints; `TlsConfig`
671    /// redacts its own key material; and the URL is redacted too, because a
672    /// `user:password@` authority is refused at fetch time rather than at
673    /// construction — so a source carrying one can be printed.
674    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675        formatter
676            .debug_struct("ConfigServer")
677            .field("url", &redacted(&self.url, LoneAuthority::Username))
678            .field("application", &self.application)
679            .field("profile", &self.profile)
680            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
681            .field("token_file", &self.token_file)
682            .field("tls", &self.tls)
683            .field("timeout", &self.timeout)
684            .finish()
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn a_document_is_lifted_out_of_the_servers_envelope() {
694        let text = r#"{"application":"billing","profile":"prod","generation":7,
695                       "config":{"port":8080}}"#;
696
697        assert_eq!(extract(text, "a server").unwrap(), r#"{"port":8080}"#);
698    }
699
700    #[test]
701    fn an_answer_from_something_that_is_not_a_config_server_says_so() {
702        let error = extract(r#"{"hello":"world"}"#, "a server").unwrap_err();
703
704        assert!(error.to_string().contains("no `config` member"), "{error}");
705    }
706
707    /// A password in the URL is refused rather than sent — and the refusal
708    /// must not be where it gets printed. `new` cannot fail, so the source
709    /// exists, is `Debug`-printed and describes itself long before the
710    /// parser gets to say no.
711    #[test]
712    fn a_password_in_the_url_reaches_neither_debug_nor_a_message() {
713        let source = ConfigServer::new(
714            "https://user:hunter2-do-not-print@config.internal",
715            "billing",
716            "prod",
717        );
718
719        let rendered = format!("{source:?}");
720        assert!(!rendered.contains("hunter2"), "{rendered}");
721
722        let described = source.describe();
723        assert!(!described.contains("hunter2"), "{described}");
724        assert!(described.contains("user:***@"), "{described}");
725
726        // And the refusal itself, which quotes the description.
727        let error = Endpoint::parse(&source.url, &source.described)
728            .expect_err("a `user:password@` authority is refused");
729        assert!(!error.to_string().contains("hunter2"), "{error}");
730    }
731
732    #[test]
733    fn a_token_never_reaches_debug() {
734        let source = ConfigServer::new("https://config.internal", "billing", "prod")
735            .with_token("hunter2-do-not-print");
736
737        let rendered = format!("{source:?}");
738
739        assert!(!rendered.contains("hunter2"), "{rendered}");
740        assert!(rendered.contains("<redacted>"), "{rendered}");
741    }
742}