Skip to main content

dravr_tronc/server/
auth.rs

1// ABOUTME: Configurable bearer token authentication middleware for Axum REST APIs
2// ABOUTME: Reads API key from a caller-specified env var, allows unauthenticated when unset
3//
4// SPDX-License-Identifier: MIT OR Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Shared-bearer-key middleware for Axum, and the startup posture check.
8//!
9//! [`require_auth`] **fails open**: with its environment variable unset every
10//! request passes through. That is deliberate and pinned by tests — stdio and
11//! local runs depend on it — and it is why [`resolve_startup_auth`] exists, to
12//! refuse a reachable bind that nothing gates. [`startup_auth`] is that check
13//! for the common case, a service gated by one shared key, with the posture
14//! logged: every satellite used to carry its own copy of those ten lines.
15//!
16//! **Which mechanism?** This crate ships five, and the choice is made by what
17//! the caller can present, per route — not by which one has no feature flag.
18//! The table is in the crate README under "Choosing an auth mechanism"; read it
19//! before reaching for a shared bearer key, which is the wrong answer whenever
20//! the caller is another Google workload, a browser session, or a webhook.
21
22use std::env;
23use std::error::Error;
24use std::fmt;
25use std::net::IpAddr;
26
27use axum::extract::Request;
28use axum::http::StatusCode;
29use axum::middleware::Next;
30use axum::response::{IntoResponse, Response};
31use axum::Json;
32use subtle::ConstantTimeEq;
33use tracing::{info, warn};
34
35use crate::error::ErrorResponse;
36
37/// Create an Axum middleware function that validates bearer tokens
38///
39/// Reads the API key from the given environment variable on every request
40/// to allow runtime key rotation without restarting. If the variable is not
41/// set or empty, all requests pass through (development mode).
42///
43/// # Usage
44///
45/// ```rust,ignore
46/// use axum::middleware;
47/// use dravr_tronc::server::auth::require_auth;
48///
49/// let app = Router::new()
50///     .route("/api/endpoint", get(handler))
51///     .layer(middleware::from_fn(|req, next| {
52///         require_auth("MY_API_KEY_ENV", req, next)
53///     }));
54/// ```
55pub async fn require_auth(env_var: &str, request: Request, next: Next) -> Response {
56    let expected_key = match env::var(env_var) {
57        Ok(key) if !key.is_empty() => key,
58        _ => return next.run(request).await,
59    };
60
61    let auth_header = request
62        .headers()
63        .get("authorization")
64        .and_then(|v| v.to_str().ok());
65
66    match auth_header.map(bearer_credential) {
67        Some(Some(token)) => {
68            if token.as_bytes().ct_eq(expected_key.as_bytes()).into() {
69                next.run(request).await
70            } else {
71                auth_error("Invalid API key")
72            }
73        }
74        Some(None) => auth_error("Authorization header must use Bearer scheme"),
75        None => auth_error("Missing Authorization header"),
76    }
77}
78
79/// The credential carried by an `Authorization` header value that uses the
80/// `Bearer` scheme, or `None` for any other scheme or an empty credential.
81///
82/// The scheme is matched case-insensitively — RFC 7235 §2.1 makes every
83/// auth-scheme case-insensitive, so `bearer x` and `BEARER x` present the same
84/// credential as `Bearer x` — and the one or more spaces separating it from the
85/// credential are consumed. Every bearer check in this crate reads the header
86/// through this function, so no two of them can disagree about which forms
87/// authenticate.
88#[must_use]
89pub fn bearer_credential(header_value: &str) -> Option<&str> {
90    let (scheme, credential) = header_value.split_once(' ')?;
91    if !scheme.eq_ignore_ascii_case("Bearer") {
92        return None;
93    }
94    let credential = credential.trim_start_matches(' ');
95    (!credential.is_empty()).then_some(credential)
96}
97
98/// Build a 401 error response
99fn auth_error(message: &str) -> Response {
100    let body = ErrorResponse::new("authentication_error", message);
101    (StatusCode::UNAUTHORIZED, Json(body)).into_response()
102}
103
104/// Whether `env_var` holds a usable API key.
105///
106/// The same rule [`require_auth`] applies: unset **or** empty means no key. One
107/// definition, so a caller cannot decide "configured" differently from the
108/// middleware that enforces it.
109#[must_use]
110pub fn api_key_configured(env_var: &str) -> bool {
111    matches!(env::var(env_var), Ok(key) if !key.is_empty())
112}
113
114/// The authentication posture a server resolved at startup.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum AuthMode {
117    /// A key is set, so every request is authenticated.
118    Enforced,
119    /// No key, but the bind is loopback-only — unauthenticated local development.
120    ///
121    /// A caller should say so at startup. Silence here is how a machine ends up
122    /// serving without a key and nobody noticing.
123    LoopbackDev,
124}
125
126/// A server was asked to serve a reachable interface with no key.
127///
128/// [`require_auth`] fails **open** when its key is unset — deliberately, so a
129/// developer can run without ceremony, and unchanged here because it is a
130/// documented contract with reverse dependencies this workspace cannot
131/// enumerate. That default is survivable on loopback and not on an interface
132/// something else can reach.
133///
134/// This is the other half: a startup check the binary opts into, because
135/// deciding to terminate a process belongs to `main`, not to a library. The
136/// middleware cannot make this call itself — it sees a request, never the bind
137/// address.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct InsecureBindError {
140    /// The non-loopback host the server was asked to bind.
141    pub host: String,
142    /// The environment variable that arms the gate, when the gate is a shared
143    /// key, so the refusal can say what to set. `None` when the gate is
144    /// something else (an [`AuthHook`](crate::mcp::auth::AuthHook), Google ID
145    /// tokens) and there is no single variable to name.
146    pub gate: Option<String>,
147}
148
149impl InsecureBindError {
150    /// A refusal of `host` for a service whose gate is not a single variable.
151    #[must_use]
152    pub fn new(host: impl Into<String>) -> Self {
153        Self {
154            host: host.into(),
155            gate: None,
156        }
157    }
158}
159
160impl fmt::Display for InsecureBindError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        write!(
163            f,
164            "refusing to start: nothing authenticates requests while binding non-loopback host \
165             '{}'. ",
166            self.host
167        )?;
168        match &self.gate {
169            Some(var) => write!(
170                f,
171                "Set {var} (every request must then carry it as a bearer token), or bind \
172                 127.0.0.1 for local development."
173            ),
174            None => {
175                f.write_str("Arm this service's gate, or bind 127.0.0.1 for local development.")
176            }
177        }
178    }
179}
180
181impl Error for InsecureBindError {}
182
183/// Whether `host` names a loopback interface.
184///
185/// Accepts `localhost` in any case, `127.0.0.0/8`, `::1`, and `::1` in its
186/// bracketed form. **Everything else is non-loopback, including `0.0.0.0`, `::`
187/// and any other name** — a name this cannot classify from its text is treated
188/// as reachable so the check fails closed rather than guessing in the caller's
189/// favour.
190///
191/// `localhost` counts as loopback because RFC 6761 §6.3 reserves it for the
192/// loopback interface, and browsers hard-wire it there: an origin of
193/// `http://localhost:3000` is a page on this machine. A name is still only a
194/// name, though. Binding one goes through the system resolver, which answers
195/// `localhost` with `127.0.0.1`, `::1`, or both — and, on a host whose resolver
196/// is configured otherwise, with anything at all. So this is a judgement of the
197/// text, made before any socket exists; [`crate::mcp::transport::http::serve`]
198/// goes further and judges every address the name resolves to before it binds.
199/// A binary that binds its own listener and wants the same certainty checks
200/// `local_addr()` of what it bound.
201#[must_use]
202pub fn is_loopback_host(host: &str) -> bool {
203    let trimmed = host.trim();
204    if trimmed.eq_ignore_ascii_case("localhost") {
205        return true;
206    }
207    let stripped = trimmed.strip_circumfix('[', ']').unwrap_or(trimmed);
208    stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
209}
210
211/// Resolve the startup posture for a bind, or refuse it.
212///
213/// - gated → [`AuthMode::Enforced`], whatever the host
214/// - ungated, loopback host → [`AuthMode::LoopbackDev`]
215/// - ungated, reachable host → [`InsecureBindError`], and the server must not start
216///
217/// `gated` is **whether anything at all will authenticate requests on this
218/// bind** — not whether a shared key is set. That distinction is the whole
219/// point of the parameter. A service gated by Google ID tokens holds no key
220/// and is correctly secured; an earlier version of this function asked only
221/// `api_key_configured`, which would have refused to start photograveur,
222/// sciotte and the platform backend — the three services in the fleet that got
223/// their auth most right. Every Cloud Run container must bind `0.0.0.0`, so a
224/// check that reads a reachable bind as unsafe on its own is wrong about the
225/// entire deployed estate.
226///
227/// Compose it with whatever actually gates you:
228///
229/// ```rust,ignore
230/// // shared key
231/// resolve_startup_auth(&args.host, api_key_configured("CAGEUX_API_TOKEN"))?;
232/// // Google ID tokens — no key to check, the middleware is the gate
233/// resolve_startup_auth(&args.host, true)?;
234/// ```
235///
236/// Call this in `main` before binding, and propagate the error. Nothing here
237/// exits the process on the caller's behalf: terminating belongs to the binary.
238///
239/// # Errors
240///
241/// Returns [`InsecureBindError`] when nothing gates the service and `host` is
242/// not loopback.
243pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
244    if gated {
245        Ok(AuthMode::Enforced)
246    } else if is_loopback_host(host) {
247        Ok(AuthMode::LoopbackDev)
248    } else {
249        Err(InsecureBindError::new(host))
250    }
251}
252
253/// Resolve the startup posture of a service gated by the shared bearer key in
254/// `env_var`, and say it: INFO when the key is set, WARN when the service is
255/// serving unauthenticated on loopback.
256///
257/// This is [`resolve_startup_auth`] composed with [`api_key_configured`], the
258/// form every key-gated service in the fleet wrote out by hand. The key it
259/// checks is the one [`require_auth`] and
260/// [`ApiKeyAuthHook`](crate::mcp::auth::ApiKeyAuthHook) enforce, so posture
261/// and enforcement read the same variable by the same rule.
262///
263/// A service gated by something other than one key (Google ID tokens, a
264/// session) calls [`resolve_startup_auth`] with its own `gated` instead.
265///
266/// # Errors
267///
268/// [`InsecureBindError`] naming `env_var` when the key is unset or empty and
269/// `host` is not loopback: nothing would authenticate requests, so the server
270/// must not start.
271pub fn startup_auth(env_var: &str, host: &str) -> Result<AuthMode, InsecureBindError> {
272    let mode = resolve_startup_auth(host, api_key_configured(env_var)).map_err(|refused| {
273        InsecureBindError {
274            gate: Some(env_var.to_owned()),
275            ..refused
276        }
277    })?;
278    match mode {
279        AuthMode::Enforced => info!(
280            gate = env_var,
281            "{env_var} set — every request must carry it as a bearer token"
282        ),
283        AuthMode::LoopbackDev => warn!(
284            gate = env_var,
285            host, "{env_var} unset — serving UNAUTHENTICATED on loopback for local development"
286        ),
287    }
288    Ok(mode)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use axum::body::Body;
295    use axum::routing::get;
296    use axum::{middleware, Router};
297    use http::Request as HttpRequest;
298    use http_body_util::BodyExt;
299    use serde_json::Value;
300    use tower::ServiceExt;
301
302    // Each test uses a unique env var to avoid races in parallel execution
303    async fn dummy_handler() -> &'static str {
304        "ok"
305    }
306
307    fn make_app(env_var: &'static str) -> Router {
308        Router::new()
309            .route("/test", get(dummy_handler))
310            .layer(middleware::from_fn(move |req, next| {
311                require_auth(env_var, req, next)
312            }))
313    }
314
315    #[tokio::test]
316    async fn no_env_allows_all_requests() {
317        const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
318        env::remove_var(ENV);
319        let app = make_app(ENV);
320        let req = HttpRequest::builder()
321            .uri("/test")
322            .body(Body::empty())
323            .expect("request"); // Safe: test assertion
324
325        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
326        assert_eq!(resp.status(), 200);
327    }
328
329    #[tokio::test]
330    async fn empty_env_allows_all_requests() {
331        const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
332        env::set_var(ENV, "");
333        let app = make_app(ENV);
334        let req = HttpRequest::builder()
335            .uri("/test")
336            .body(Body::empty())
337            .expect("request"); // Safe: test assertion
338
339        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
340        assert_eq!(resp.status(), 200);
341        env::remove_var(ENV);
342    }
343
344    #[tokio::test]
345    async fn valid_bearer_token_passes() {
346        const ENV: &str = "TRONC_AUTH_TEST_VALID";
347        env::set_var(ENV, "secret-key-123");
348        let app = make_app(ENV);
349        let req = HttpRequest::builder()
350            .uri("/test")
351            .header("authorization", "Bearer secret-key-123")
352            .body(Body::empty())
353            .expect("request"); // Safe: test assertion
354
355        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
356        assert_eq!(resp.status(), 200);
357        env::remove_var(ENV);
358    }
359
360    #[tokio::test]
361    async fn invalid_bearer_token_returns_401() {
362        const ENV: &str = "TRONC_AUTH_TEST_INVALID";
363        env::set_var(ENV, "secret-key-123");
364        let app = make_app(ENV);
365        let req = HttpRequest::builder()
366            .uri("/test")
367            .header("authorization", "Bearer wrong-key")
368            .body(Body::empty())
369            .expect("request"); // Safe: test assertion
370
371        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
372        assert_eq!(resp.status(), 401);
373
374        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
375        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
376        assert_eq!(json["error"]["type"], "authentication_error");
377        env::remove_var(ENV);
378    }
379
380    #[tokio::test]
381    async fn missing_header_returns_401() {
382        const ENV: &str = "TRONC_AUTH_TEST_MISSING";
383        env::set_var(ENV, "secret-key-123");
384        let app = make_app(ENV);
385        let req = HttpRequest::builder()
386            .uri("/test")
387            .body(Body::empty())
388            .expect("request"); // Safe: test assertion
389
390        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
391        assert_eq!(resp.status(), 401);
392        env::remove_var(ENV);
393    }
394
395    #[tokio::test]
396    async fn non_bearer_scheme_returns_401() {
397        const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
398        env::set_var(ENV, "secret-key-123");
399        let app = make_app(ENV);
400        let req = HttpRequest::builder()
401            .uri("/test")
402            .header("authorization", "Basic dXNlcjpwYXNz")
403            .body(Body::empty())
404            .expect("request"); // Safe: test assertion
405
406        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
407        assert_eq!(resp.status(), 401);
408
409        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
410        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
411        assert!(json["error"]["message"]
412            .as_str()
413            .expect("msg") // Safe: test assertion
414            .contains("Bearer"));
415        env::remove_var(ENV);
416    }
417
418    /// RFC 7235 §2.1: the auth-scheme is case-insensitive. A client sending
419    /// `bearer` in lower case holds the right key and used to get a 401.
420    #[tokio::test]
421    async fn lowercase_bearer_scheme_passes() {
422        const ENV: &str = "TRONC_AUTH_TEST_LOWERCASE_SCHEME";
423        env::set_var(ENV, "secret-key-123");
424        let app = make_app(ENV);
425        let req = HttpRequest::builder()
426            .uri("/test")
427            .header("authorization", "bearer secret-key-123")
428            .body(Body::empty())
429            .expect("request"); // Safe: test assertion
430
431        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
432        assert_eq!(resp.status(), 200);
433        env::remove_var(ENV);
434    }
435
436    #[test]
437    fn bearer_credential_reads_the_scheme_in_any_case() {
438        for header in [
439            "Bearer tok-1",
440            "bearer tok-1",
441            "BEARER tok-1",
442            "BeArEr tok-1",
443            "Bearer   tok-1",
444        ] {
445            assert_eq!(
446                bearer_credential(header),
447                Some("tok-1"),
448                "{header:?} carries the credential tok-1"
449            );
450        }
451    }
452
453    #[test]
454    fn bearer_credential_refuses_other_schemes_and_empty_credentials() {
455        for header in [
456            "Basic dXNlcjpwYXNz",
457            "Bearertok-1",
458            "Bearer",
459            "Bearer ",
460            "Bearer    ",
461            "tok-1",
462            "",
463        ] {
464            assert_eq!(
465                bearer_credential(header),
466                None,
467                "{header:?} must not read as a bearer credential"
468            );
469        }
470    }
471
472    // ---- startup posture ----
473
474    #[test]
475    fn loopback_hosts_are_recognised() {
476        for host in [
477            "127.0.0.1",
478            "127.1.2.3",
479            "::1",
480            "[::1]",
481            "localhost",
482            "LocalHost",
483        ] {
484            assert!(is_loopback_host(host), "{host} should be loopback");
485        }
486    }
487
488    #[test]
489    fn anything_reachable_or_unparseable_is_not_loopback() {
490        // 0.0.0.0 and :: are the wildcards that bind every interface, and an
491        // unresolvable name is treated as reachable so the check fails closed
492        // rather than guessing in the caller's favour.
493        for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", "   "] {
494            assert!(
495                !is_loopback_host(host),
496                "{host:?} must not read as loopback"
497            );
498        }
499    }
500
501    #[test]
502    fn a_reachable_bind_with_nothing_gating_it_is_refused() {
503        let err = resolve_startup_auth("0.0.0.0", false)
504            .expect_err("a reachable bind with no gate must be refused"); // Safe: test assertion
505
506        assert_eq!(err.host, "0.0.0.0");
507        let msg = err.to_string();
508        assert!(msg.contains("refusing to start"), "message was {msg:?}");
509        assert!(
510            msg.contains("0.0.0.0"),
511            "the message must name the bind that was refused; message was {msg:?}"
512        );
513    }
514
515    #[test]
516    fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
517        // The regression this pins: an earlier draft resolved posture from
518        // `api_key_configured` alone. Every Cloud Run container binds 0.0.0.0,
519        // and the three services in the fleet with the strongest auth —
520        // photograveur, sciotte, the platform backend — gate on Google ID
521        // tokens and hold no API key at all. Under the key-only rule each one
522        // refuses to start. `gated` is "something authenticates requests",
523        // whatever that something is.
524        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
525        env::remove_var(ENV);
526        assert!(!api_key_configured(ENV));
527
528        assert_eq!(
529            resolve_startup_auth("0.0.0.0", true),
530            Ok(AuthMode::Enforced)
531        );
532    }
533
534    #[test]
535    fn loopback_without_a_gate_is_development_not_a_refusal() {
536        assert_eq!(
537            resolve_startup_auth("127.0.0.1", false),
538            Ok(AuthMode::LoopbackDev)
539        );
540    }
541
542    #[test]
543    fn a_gate_enforces_on_every_host() {
544        assert_eq!(
545            resolve_startup_auth("0.0.0.0", true),
546            Ok(AuthMode::Enforced)
547        );
548        assert_eq!(
549            resolve_startup_auth("127.0.0.1", true),
550            Ok(AuthMode::Enforced)
551        );
552    }
553
554    // ---- startup_auth: the key-gated posture, logged ----
555
556    /// Everything a subscriber wrote while `f` ran, as text.
557    fn captured_logs(f: impl FnOnce()) -> String {
558        use std::io;
559        use std::sync::{Arc, Mutex, PoisonError};
560        use tracing::subscriber::with_default;
561        use tracing_subscriber::fmt::MakeWriter;
562
563        #[derive(Clone, Default)]
564        struct Buffer(Arc<Mutex<Vec<u8>>>);
565
566        impl io::Write for Buffer {
567            fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
568                self.0
569                    .lock()
570                    .unwrap_or_else(PoisonError::into_inner)
571                    .extend_from_slice(bytes);
572                Ok(bytes.len())
573            }
574
575            fn flush(&mut self) -> io::Result<()> {
576                Ok(())
577            }
578        }
579
580        impl<'a> MakeWriter<'a> for Buffer {
581            type Writer = Self;
582
583            fn make_writer(&'a self) -> Self::Writer {
584                self.clone()
585            }
586        }
587
588        let buffer = Buffer::default();
589        let subscriber = tracing_subscriber::fmt()
590            .with_writer(buffer.clone())
591            .with_ansi(false)
592            .finish();
593        with_default(subscriber, f);
594        let bytes = buffer
595            .0
596            .lock()
597            .unwrap_or_else(PoisonError::into_inner)
598            .clone();
599        String::from_utf8(bytes).expect("utf-8 log") // Safe: test assertion
600    }
601
602    #[test]
603    fn startup_auth_refuses_a_reachable_bind_and_names_the_key_to_set() {
604        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_REFUSED";
605        env::remove_var(ENV);
606
607        let err =
608            startup_auth(ENV, "0.0.0.0").expect_err("no key on a reachable bind must be refused"); // Safe: test assertion
609
610        assert_eq!(err.host, "0.0.0.0");
611        assert_eq!(err.gate.as_deref(), Some(ENV));
612        let msg = err.to_string();
613        assert!(
614            msg.contains(&format!("Set {ENV}")),
615            "the refusal must name the variable that arms the gate; message was {msg:?}"
616        );
617    }
618
619    #[test]
620    fn startup_auth_enforces_when_the_key_is_set_and_says_so() {
621        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_ENFORCED";
622        env::set_var(ENV, "k-123");
623
624        let mut mode = None;
625        let logs = captured_logs(|| mode = Some(startup_auth(ENV, "0.0.0.0")));
626
627        assert_eq!(mode, Some(Ok(AuthMode::Enforced)));
628        assert!(logs.contains(" INFO "), "logs were {logs:?}");
629        assert!(
630            logs.contains(&format!("{ENV} set")),
631            "the INFO line must name the key; logs were {logs:?}"
632        );
633        assert!(
634            !logs.contains("k-123"),
635            "the key's value must never be logged"
636        );
637        env::remove_var(ENV);
638    }
639
640    #[test]
641    fn startup_auth_warns_when_loopback_serves_unauthenticated() {
642        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_LOOPBACK";
643        env::set_var(ENV, "");
644
645        let mut mode = None;
646        let logs = captured_logs(|| mode = Some(startup_auth(ENV, "localhost")));
647
648        assert_eq!(mode, Some(Ok(AuthMode::LoopbackDev)));
649        assert!(logs.contains(" WARN "), "logs were {logs:?}");
650        assert!(
651            logs.contains(&format!("{ENV} unset")) && logs.contains("UNAUTHENTICATED"),
652            "the WARN line must say which key is missing; logs were {logs:?}"
653        );
654        env::remove_var(ENV);
655    }
656
657    #[test]
658    fn a_refusal_with_no_single_gate_asks_for_the_gate_generically() {
659        let msg = resolve_startup_auth("0.0.0.0", false)
660            .expect_err("refused") // Safe: test assertion
661            .to_string();
662        assert!(
663            msg.contains("Arm this service's gate"),
664            "message was {msg:?}"
665        );
666    }
667
668    #[test]
669    fn an_empty_key_is_no_key_here_too() {
670        // require_auth treats unset and empty alike; a caller composing
671        // api_key_configured into resolve_startup_auth must agree, or a server
672        // boots believing it is Enforced while every request sails through.
673        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
674        env::set_var(ENV, "");
675        assert!(!api_key_configured(ENV));
676        assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
677        env::remove_var(ENV);
678    }
679}