Skip to main content

guard_core_engine/
headers_auth.rs

1//! The required-headers and authentication gate.
2//!
3//! This is the Rust family's port of two adjacent reference checks,
4//! `guard_core/core/checks/implementations/required_headers.py` and
5//! `guard_core/core/checks/implementations/authentication.py` (with the
6//! credential extraction of `guard_core/core/checks/helpers.py::extract_credential`),
7//! evaluated in the reference pipeline's order: required headers first, then
8//! authentication. One call, [`decide`], carries a request's header values
9//! through both:
10//!
11//! ```text
12//! required headers, first failing rule in configured order:
13//!   header absent or empty:          400 "Missing required header: {name}"
14//!   value != expected (sentinel
15//!   "required" = presence only):     400 "Header '{name}' does not match the required value"
16//! authentication:
17//!   authorization_header_required set: presence-only scheme check on
18//!                                      the authorization header
19//!   auth_required set: scheme check, then the auth verifier decides
20//!   api_key_required set: the api key header must carry a value, then the
21//!                         api key verifier decides
22//!   any auth failure:                401 "Authentication required"
23//! ```
24//!
25//! ## Details that mirror the reference exactly
26//!
27//! - A required header that is present but empty counts as missing
28//!   (`if not actual` in the reference), expected values compare
29//!   case-sensitively, and the `"required"` sentinel means presence-only.
30//!   The block body is the reference's dynamic default message, which is
31//!   why it is an owned [`String`] here.
32//! - Credential extraction is `extract_credential`: the scheme names
33//!   `"bearer"` and `"basic"` (exact, lowercase) require the exact `Bearer `
34//!   / `Basic ` prefix and carry the remainder as the credential; any other
35//!   scheme accepts the whole header value as the credential and fails only
36//!   when it is empty.
37//! - The presence check (`authorization_header_required`) short-circuits:
38//!   when it is set, `auth_required`/`api_key_required` are not consulted,
39//!   exactly as the reference check returns after it.
40//! - Verifier resolution: the route's `auth_verifier` (or `api_key_verifier`
41//!   on the API-key path) wins, the global `auth_verifier` is the fallback;
42//!   a required credential with no verifier at all is a failure
43//!   ("No auth verifier configured"), as in the reference. All failures
44//!   answer the same body, `401 "Authentication required"` (the reasons are
45//!   observability-only in the reference too).
46//!
47//! The header lookup is a closure (`name -> Option<&str>`, case-insensitive
48//! on the name), so adapters feed it from `HeaderMap`, actix, or Rocket
49//! types alike.
50//!
51//! # Example
52//!
53//! ```
54//! use std::sync::Arc;
55//!
56//! use guard_core_engine::headers_auth::{
57//!     decide, HeaderAuthRules, RequiredHeader, RouteVerifiers, REQUIRED_SENTINEL,
58//! };
59//!
60//! let rules = HeaderAuthRules {
61//!     required_headers: vec![RequiredHeader {
62//!         name: "X-Request-ID".to_owned(),
63//!         expected: REQUIRED_SENTINEL.to_owned(),
64//!     }],
65//!     auth_required: Some("bearer".to_owned()),
66//!     ..HeaderAuthRules::default()
67//! };
68//! let verifiers = RouteVerifiers {
69//!     auth: Some(Arc::new(|credential| credential == "token-1")),
70//!     api_key: None,
71//! };
72//! let headers = [
73//!     ("X-Request-ID", "abc"),
74//!     ("Authorization", "Bearer token-1"),
75//! ];
76//! let lookup = |name: &str| {
77//!     headers
78//!         .iter()
79//!         .find(|(key, _)| key.eq_ignore_ascii_case(name))
80//!         .map(|(_, value)| (*value).to_owned())
81//! };
82//!
83//! // Everything present: pass.
84//! assert!(decide(&rules, &verifiers, lookup).is_none());
85//!
86//! // A missing required header is the reference's dynamic 400 body.
87//! let block = decide(&rules, &verifiers, |_| None).expect("missing header");
88//! assert_eq!(block.status, 400);
89//! assert_eq!(block.body, "Missing required header: X-Request-ID");
90//!
91//! // A bad bearer credential is the fixed 401 shape.
92//! let block = decide(
93//!     &rules,
94//!     &verifiers,
95//!     |name| {
96//!         if name.eq_ignore_ascii_case("Authorization") {
97//!             Some("Bearer wrong".to_owned())
98//!         } else {
99//!             Some("abc".to_owned())
100//!         }
101//!     },
102//! )
103//! .expect("auth failure");
104//! assert_eq!(block.status, 401);
105//! assert_eq!(block.body, "Authentication required");
106//! ```
107
108use std::fmt;
109use std::sync::Arc;
110
111/// The required-header expected value meaning presence only
112/// (`required_headers[name] == "required"` in the reference).
113pub const REQUIRED_SENTINEL: &str = "required";
114
115/// One required-header rule, in configured order: `name` must be present,
116/// and its value must equal `expected` unless `expected` is
117/// [`REQUIRED_SENTINEL`].
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RequiredHeader {
120    /// The header name (looked up case-insensitively).
121    pub name: String,
122    /// The exact required value, or [`REQUIRED_SENTINEL`].
123    pub expected: String,
124}
125
126/// The route-level header and authentication rules.
127///
128/// The reference `RouteConfig` fields `required_headers`, `auth_required`,
129/// `api_key_required`, `authorization_header_required`, and
130/// `api_key_header`. The default has no rules: the gate passes.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct HeaderAuthRules {
133    /// `required_headers`: checked in order, before authentication.
134    pub required_headers: Vec<RequiredHeader>,
135    /// `auth_required`: the authentication scheme
136    /// (`"bearer"`, `"basic"`, or any other scheme name).
137    pub auth_required: Option<String>,
138    /// `api_key_required`: demand a non-empty `api_key_header` value.
139    pub api_key_required: bool,
140    /// `authorization_header_required`: a presence-only scheme check that
141    /// short-circuits the other two authentication paths.
142    pub authorization_header_required: Option<String>,
143    /// `api_key_header`: the header the API key is read from.
144    pub api_key_header: Option<String>,
145}
146
147impl HeaderAuthRules {
148    /// Whether any rule is configured (the reference `applies_to`
149    /// predicates).
150    #[must_use]
151    pub const fn has_rules(&self) -> bool {
152        !self.required_headers.is_empty()
153            || self.auth_required.is_some()
154            || self.api_key_required
155            || self.authorization_header_required.is_some()
156    }
157}
158
159/// A credential verifier: `credential -> accepted?`.
160///
161/// The reference verifier is `verifier(request, credential) -> Principal |
162/// None`; this port drops the request argument and the principal payload
163/// (the stage discards both) and cannot observe the reference's
164/// verifier-exception case.
165pub type AuthVerifier = Arc<dyn Fn(&str) -> bool + Send + Sync>;
166
167/// The verifiers one route resolves to, after the caller merged the global
168/// `auth_verifier` fallback (route verifier wins).
169#[derive(Clone, Default)]
170pub struct RouteVerifiers {
171    /// The `auth_required` path's verifier.
172    pub auth: Option<AuthVerifier>,
173    /// The `api_key_required` path's verifier.
174    pub api_key: Option<AuthVerifier>,
175}
176
177impl fmt::Debug for RouteVerifiers {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.debug_struct("RouteVerifiers")
180            .field("auth", &self.auth.is_some())
181            .field("api_key", &self.api_key.is_some())
182            .finish()
183    }
184}
185
186/// Which rule failed (the reference's `violation_type` values, carried for
187/// observability).
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum GuardViolation {
190    /// `required_headers` entry absent or empty.
191    MissingHeader,
192    /// `required_headers` entry with a different value.
193    MismatchedHeader,
194    /// The `authorization_header_required` presence check failed
195    /// (`violation_type="authorization_header"`).
196    AuthorizationHeader,
197    /// The `auth_required` / `api_key_required` path failed
198    /// (`violation_type="require_auth"`).
199    RequireAuth,
200}
201
202/// The block answer of a failed header or authentication rule: the status,
203/// the reference default message body (dynamic for the required-header
204/// shapes, which is why it is owned), and the violation type.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct GuardBlock {
207    /// Which rule failed.
208    pub violation: GuardViolation,
209    /// `400` for the required-header shapes, `401` for every
210    /// authentication failure.
211    pub status: u16,
212    /// The reference default message body.
213    pub body: String,
214}
215
216impl fmt::Display for GuardBlock {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        write!(f, "{} {}", self.status, self.body)
219    }
220}
221
222/// The reference `extract_credential`: the credential of an
223/// `authorization` header value under a scheme.
224///
225/// `"bearer"` requires the exact `Bearer ` prefix and `"basic"` the exact
226/// `Basic ` prefix (each carrying the remainder as the credential); any
227/// other scheme carries the whole header value and fails only when empty.
228/// `None` means the reference returned a failure reason.
229#[must_use]
230pub fn extract_credential<'a>(auth_header: &'a str, auth_type: &str) -> Option<&'a str> {
231    match auth_type {
232        "bearer" => auth_header.strip_prefix("Bearer "),
233        "basic" => auth_header.strip_prefix("Basic "),
234        _ if auth_header.is_empty() => None,
235        _ => Some(auth_header),
236    }
237}
238
239/// Carry a request's header values through the required-headers check and
240/// then the authentication check, in the reference pipeline's order.
241///
242/// `lookup` reads a header value case-insensitively by name (`None` when
243/// absent). `Ok`-shaped for future-proofing is not needed: the checks are
244/// total, so the return is a plain `Option` - `None` passes,
245/// `Some(block)` is the reference's block answer.
246#[must_use]
247pub fn decide<L>(
248    rules: &HeaderAuthRules,
249    verifiers: &RouteVerifiers,
250    lookup: L,
251) -> Option<GuardBlock>
252where
253    L: Fn(&str) -> Option<String>,
254{
255    required_headers_block(rules, &lookup)
256        .or_else(|| authentication_block(rules, verifiers, &lookup))
257}
258
259/// The required-headers sub-check: the first failing rule in order.
260fn required_headers_block<L>(rules: &HeaderAuthRules, lookup: &L) -> Option<GuardBlock>
261where
262    L: Fn(&str) -> Option<String>,
263{
264    for rule in &rules.required_headers {
265        let actual = lookup(&rule.name).filter(|value| !value.is_empty());
266        let Some(actual) = actual else {
267            return Some(GuardBlock {
268                violation: GuardViolation::MissingHeader,
269                status: 400,
270                body: format!("Missing required header: {}", rule.name),
271            });
272        };
273        if rule.expected != REQUIRED_SENTINEL && actual != rule.expected {
274            return Some(GuardBlock {
275                violation: GuardViolation::MismatchedHeader,
276                status: 400,
277                body: format!("Header '{}' does not match the required value", rule.name),
278            });
279        }
280    }
281    None
282}
283
284/// The authentication sub-check, in the reference's branch order.
285fn authentication_block<L>(
286    rules: &HeaderAuthRules,
287    verifiers: &RouteVerifiers,
288    lookup: &L,
289) -> Option<GuardBlock>
290where
291    L: Fn(&str) -> Option<String>,
292{
293    // The presence check short-circuits everything else.
294    if let Some(scheme) = rules.authorization_header_required.as_deref() {
295        let auth_header = lookup("authorization").unwrap_or_default();
296        return extract_credential(&auth_header, scheme).map_or_else(
297            || {
298                Some(GuardBlock {
299                    violation: GuardViolation::AuthorizationHeader,
300                    status: 401,
301                    body: "Authentication required".to_owned(),
302                })
303            },
304            |_| None,
305        );
306    }
307
308    if let Some(scheme) = rules.auth_required.as_deref() {
309        let auth_header = lookup("authorization").unwrap_or_default();
310        let Some(credential) = extract_credential(&auth_header, scheme) else {
311            return Some(auth_failure(GuardViolation::RequireAuth));
312        };
313        return resolve_verifier(verifiers.auth.as_ref(), credential);
314    }
315
316    if rules.api_key_required {
317        let header = rules.api_key_header.as_deref().unwrap_or("");
318        let credential = lookup(header).filter(|value| !value.is_empty());
319        let Some(credential) = credential else {
320            return Some(auth_failure(GuardViolation::RequireAuth));
321        };
322        return resolve_verifier(verifiers.api_key.as_ref(), &credential);
323    }
324
325    None
326}
327
328/// The reference's "No auth verifier configured" / "Authentication failed"
329/// pair: both answer the fixed 401 shape.
330fn resolve_verifier(verifier: Option<&AuthVerifier>, credential: &str) -> Option<GuardBlock> {
331    let Some(verifier) = verifier else {
332        return Some(auth_failure(GuardViolation::RequireAuth));
333    };
334    if verifier(credential) {
335        None
336    } else {
337        Some(auth_failure(GuardViolation::RequireAuth))
338    }
339}
340
341/// The reference `create_error_response(status_code=401,
342/// default_message="Authentication required")` shape every auth failure
343/// answers with.
344fn auth_failure(violation: GuardViolation) -> GuardBlock {
345    GuardBlock {
346        violation,
347        status: 401,
348        body: "Authentication required".to_owned(),
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    /// A lookup over case-insensitive name/value pairs, in the owned shape
357    /// `decide` reads.
358    fn lookup_of<'h>(headers: &'h [(&'h str, &'h str)]) -> impl Fn(&str) -> Option<String> + 'h {
359        move |name: &str| {
360            headers
361                .iter()
362                .find(|(key, _)| key.eq_ignore_ascii_case(name))
363                .map(|(_, value)| (*value).to_owned())
364        }
365    }
366
367    fn bearer_rules(scheme: &str) -> HeaderAuthRules {
368        HeaderAuthRules {
369            auth_required: Some(scheme.to_owned()),
370            ..HeaderAuthRules::default()
371        }
372    }
373
374    #[test]
375    fn empty_rules_pass_everything() {
376        let rules = HeaderAuthRules::default();
377        assert!(!rules.has_rules());
378        assert!(decide(&rules, &RouteVerifiers::default(), |_| None).is_none());
379    }
380
381    #[test]
382    fn has_rules_tracks_each_field() {
383        let rules = HeaderAuthRules {
384            api_key_required: true,
385            ..HeaderAuthRules::default()
386        };
387        assert!(rules.has_rules());
388        let rules = HeaderAuthRules {
389            authorization_header_required: Some("bearer".to_owned()),
390            ..HeaderAuthRules::default()
391        };
392        assert!(rules.has_rules());
393    }
394
395    #[test]
396    fn required_header_missing_or_empty_is_a_dynamic_400() {
397        let rules = HeaderAuthRules {
398            required_headers: vec![RequiredHeader {
399                name: "X-Request-ID".to_owned(),
400                expected: REQUIRED_SENTINEL.to_owned(),
401            }],
402            ..HeaderAuthRules::default()
403        };
404        let block = decide(&rules, &RouteVerifiers::default(), |_| None).expect("missing");
405        assert_eq!(block.violation, GuardViolation::MissingHeader);
406        assert_eq!(block.status, 400);
407        assert_eq!(block.body, "Missing required header: X-Request-ID");
408        assert_eq!(
409            block.to_string(),
410            "400 Missing required header: X-Request-ID"
411        );
412
413        // Present but empty counts as missing (`if not actual`).
414        let block = decide(
415            &rules,
416            &RouteVerifiers::default(),
417            lookup_of(&[("X-Request-ID", "")]),
418        )
419        .expect("empty");
420        assert_eq!(block.violation, GuardViolation::MissingHeader);
421    }
422
423    #[test]
424    fn required_header_lookup_is_case_insensitive_and_values_exact() {
425        let rules = HeaderAuthRules {
426            required_headers: vec![RequiredHeader {
427                name: "X-Role".to_owned(),
428                expected: "admin".to_owned(),
429            }],
430            ..HeaderAuthRules::default()
431        };
432        assert!(
433            decide(
434                &rules,
435                &RouteVerifiers::default(),
436                lookup_of(&[("x-role", "admin")])
437            )
438            .is_none()
439        );
440        // The value comparison is case-sensitive.
441        let block = decide(
442            &rules,
443            &RouteVerifiers::default(),
444            lookup_of(&[("X-ROLE", "Admin")]),
445        )
446        .expect("mismatch");
447        assert_eq!(block.violation, GuardViolation::MismatchedHeader);
448        assert_eq!(block.status, 400);
449        assert_eq!(
450            block.body,
451            "Header 'X-Role' does not match the required value"
452        );
453    }
454
455    #[test]
456    fn required_headers_report_the_first_failing_rule_in_order() {
457        let rules = HeaderAuthRules {
458            required_headers: vec![
459                RequiredHeader {
460                    name: "A".to_owned(),
461                    expected: REQUIRED_SENTINEL.to_owned(),
462                },
463                RequiredHeader {
464                    name: "B".to_owned(),
465                    expected: "yes".to_owned(),
466                },
467                RequiredHeader {
468                    name: "C".to_owned(),
469                    expected: REQUIRED_SENTINEL.to_owned(),
470                },
471            ],
472            ..HeaderAuthRules::default()
473        };
474        let lookup = lookup_of(&[("A", "1"), ("B", "no"), ("C", "")]);
475        let block = decide(&rules, &RouteVerifiers::default(), lookup).expect("B fails");
476        assert_eq!(block.body, "Header 'B' does not match the required value");
477
478        let lookup = lookup_of(&[("A", "1"), ("B", "yes")]);
479        let block = decide(&rules, &RouteVerifiers::default(), lookup).expect("C fails");
480        assert_eq!(block.body, "Missing required header: C");
481    }
482
483    #[test]
484    fn bearer_scheme_demands_the_exact_prefix() {
485        let verifiers = RouteVerifiers::default();
486        // No verifier configured: a valid credential still fails with the
487        // "No auth verifier configured" shape (the fixed 401 body).
488        assert!(
489            decide(
490                &bearer_rules("bearer"),
491                &verifiers,
492                lookup_of(&[("authorization", "Bearer tok")])
493            )
494            .is_some()
495        );
496
497        let rules = bearer_rules("bearer");
498        let verifiers = RouteVerifiers {
499            auth: Some(Arc::new(|_| true)),
500            api_key: None,
501        };
502        assert!(
503            decide(
504                &rules,
505                &verifiers,
506                lookup_of(&[("authorization", "Bearer tok")])
507            )
508            .is_none()
509        );
510        // Case of the scheme prefix matters; the scheme name has no case
511        // variants ("bearer" is matched exactly).
512        for junk in ["bearer tok", "Bearer", "Basic tok", "tok"] {
513            assert!(
514                decide(&rules, &verifiers, lookup_of(&[("authorization", junk)])).is_some(),
515                "'{junk}' must fail the bearer presence check"
516            );
517        }
518        // The prefix alone still carries an (empty) credential and passes,
519        // like Python's `auth_header[len("Bearer "):]` returning "".
520        assert!(
521            decide(
522                &rules,
523                &verifiers,
524                lookup_of(&[("authorization", "Bearer ")])
525            )
526            .is_none()
527        );
528        // A missing authorization header fails too.
529        assert!(decide(&rules, &verifiers, |_| None).is_some());
530    }
531
532    #[test]
533    fn basic_scheme_demands_the_exact_prefix() {
534        let rules = bearer_rules("basic");
535        let verifiers = RouteVerifiers {
536            auth: Some(Arc::new(|_| true)),
537            api_key: None,
538        };
539        assert!(
540            decide(
541                &rules,
542                &verifiers,
543                lookup_of(&[("authorization", "Basic dXNlcjpwdw==")])
544            )
545            .is_none()
546        );
547        assert!(
548            decide(
549                &rules,
550                &verifiers,
551                lookup_of(&[("authorization", "Bearer dXNlcjpwdw==")])
552            )
553            .is_some()
554        );
555    }
556
557    #[test]
558    fn other_schemes_carry_the_whole_header_value() {
559        let rules = bearer_rules("apikey");
560        let verifiers = RouteVerifiers {
561            auth: Some(Arc::new(|credential| credential == "raw-key")),
562            api_key: None,
563        };
564        assert!(
565            decide(
566                &rules,
567                &verifiers,
568                lookup_of(&[("authorization", "raw-key")])
569            )
570            .is_none()
571        );
572        // The whole value, prefix included, is the credential.
573        assert!(
574            decide(
575                &rules,
576                &verifiers,
577                lookup_of(&[("authorization", "Bearer raw-key")])
578            )
579            .is_some()
580        );
581        // Empty is the only failure of the presence half.
582        assert!(decide(&rules, &verifiers, lookup_of(&[("authorization", "")])).is_some());
583    }
584
585    #[test]
586    fn verifier_outcomes_are_the_fixed_401_shape() {
587        let rules = bearer_rules("bearer");
588        let accept = RouteVerifiers {
589            auth: Some(Arc::new(|_| true)),
590            api_key: None,
591        };
592        let reject = RouteVerifiers {
593            auth: Some(Arc::new(|_| false)),
594            api_key: None,
595        };
596        assert!(decide(&rules, &accept, lookup_of(&[("authorization", "Bearer t")])).is_none());
597        let block =
598            decide(&rules, &reject, lookup_of(&[("authorization", "Bearer t")])).expect("rejected");
599        assert_eq!(block.violation, GuardViolation::RequireAuth);
600        assert_eq!(block.status, 401);
601        assert_eq!(block.body, "Authentication required");
602
603        // No verifier at all is the reference's "No auth verifier
604        // configured" failure, same body.
605        let block = decide(
606            &rules,
607            &RouteVerifiers::default(),
608            lookup_of(&[("authorization", "Bearer t")]),
609        )
610        .expect("no verifier");
611        assert_eq!(block.status, 401);
612        assert_eq!(block.body, "Authentication required");
613    }
614
615    #[test]
616    fn presence_scheme_short_circuits_the_other_auth_paths() {
617        let rules = HeaderAuthRules {
618            auth_required: Some("bearer".to_owned()),
619            api_key_required: true,
620            api_key_header: Some("X-Key".to_owned()),
621            authorization_header_required: Some("basic".to_owned()),
622            ..HeaderAuthRules::default()
623        };
624        // A valid Basic header passes even though auth_required and
625        // api_key_required would both fail: the presence check returned.
626        let verifiers = RouteVerifiers::default();
627        assert!(
628            decide(
629                &rules,
630                &verifiers,
631                lookup_of(&[("authorization", "Basic ok")])
632            )
633            .is_none()
634        );
635        // The prefix alone carries an empty credential and passes as well.
636        assert!(
637            decide(
638                &rules,
639                &verifiers,
640                lookup_of(&[("authorization", "Basic ")])
641            )
642            .is_none()
643        );
644        // A wrong prefix fails with the presence violation.
645        let block = decide(
646            &rules,
647            &verifiers,
648            lookup_of(&[("authorization", "bearer x")]),
649        )
650        .expect("presence");
651        assert_eq!(block.violation, GuardViolation::AuthorizationHeader);
652    }
653
654    #[test]
655    fn api_key_path_reads_its_header_and_verifier() {
656        let rules = HeaderAuthRules {
657            api_key_required: true,
658            api_key_header: Some("X-Key".to_owned()),
659            ..HeaderAuthRules::default()
660        };
661        let verifiers = RouteVerifiers {
662            auth: None,
663            api_key: Some(Arc::new(|credential| credential == "k-1")),
664        };
665        assert!(decide(&rules, &verifiers, lookup_of(&[("X-Key", "k-1")])).is_none());
666        // Missing, empty, or rejected all fail with the 401 shape.
667        for headers in [
668            vec![],
669            vec![("X-Key", "")],
670            vec![("X-Key", "wrong")],
671            vec![("X-Other", "k-1")],
672        ] {
673            let block = decide(&rules, &verifiers, lookup_of(&headers)).expect("api key failure");
674            assert_eq!(block.violation, GuardViolation::RequireAuth);
675            assert_eq!(block.status, 401);
676        }
677        // No configured header name reads nothing ("api_key_header or \"\"").
678        let rules = HeaderAuthRules {
679            api_key_required: true,
680            ..HeaderAuthRules::default()
681        };
682        let verifiers = RouteVerifiers {
683            api_key: Some(Arc::new(|_| true)),
684            auth: None,
685        };
686        assert!(decide(&rules, &verifiers, lookup_of(&[("X-Key", "k-1")])).is_some());
687    }
688
689    #[test]
690    fn required_headers_run_before_authentication() {
691        let rules = HeaderAuthRules {
692            required_headers: vec![RequiredHeader {
693                name: "X-Request-ID".to_owned(),
694                expected: REQUIRED_SENTINEL.to_owned(),
695            }],
696            auth_required: Some("bearer".to_owned()),
697            ..HeaderAuthRules::default()
698        };
699        let verifiers = RouteVerifiers::default();
700        // Both would fail: the missing header answers first, with the 400.
701        let block = decide(&rules, &verifiers, |_| None).expect("header first");
702        assert_eq!(block.status, 400);
703        assert_eq!(block.body, "Missing required header: X-Request-ID");
704    }
705
706    #[test]
707    fn auth_required_beats_api_key_when_both_set() {
708        let rules = HeaderAuthRules {
709            auth_required: Some("bearer".to_owned()),
710            api_key_required: true,
711            api_key_header: Some("X-Key".to_owned()),
712            ..HeaderAuthRules::default()
713        };
714        let verifiers = RouteVerifiers {
715            auth: Some(Arc::new(|_| true)),
716            api_key: Some(Arc::new(|_| false)),
717        };
718        // Only the bearer credential present: the auth path passes and the
719        // api key path is never reached (the reference returns after
720        // _resolve_credential).
721        assert!(
722            decide(
723                &rules,
724                &verifiers,
725                lookup_of(&[("authorization", "Bearer t")])
726            )
727            .is_none()
728        );
729    }
730
731    #[test]
732    fn route_verifiers_debug_renders_the_verifier_presence() {
733        let verifiers = RouteVerifiers {
734            auth: None,
735            api_key: None,
736        };
737        assert!(format!("{verifiers:?}").contains("RouteVerifiers"));
738    }
739}