Skip to main content

gatekeep_axum/
test_support.rs

1//! Test helpers for asserting gatekeep axum denial responses.
2
3use axum::{
4    body::{Body, to_bytes},
5    http::StatusCode,
6    response::Response,
7};
8use thiserror::Error;
9
10use crate::{DenialBody, DenialError};
11
12const DENIAL_BODY_FIELDS: &[&str] = &["error", "message", "reason"];
13
14/// Expected HTTP denial response.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ExpectedDenial {
17    status: StatusCode,
18    error: DenialError,
19    message: Option<String>,
20    reason: ExpectedReason,
21}
22
23impl ExpectedDenial {
24    /// Expects a visible forbidden denial.
25    #[must_use]
26    pub const fn forbidden() -> Self {
27        Self {
28            status: StatusCode::FORBIDDEN,
29            error: DenialError::Forbidden,
30            message: None,
31            reason: ExpectedReason::Any,
32        }
33    }
34
35    /// Expects a hidden not-found denial.
36    #[must_use]
37    pub const fn not_found() -> Self {
38        Self {
39            status: StatusCode::NOT_FOUND,
40            error: DenialError::NotFound,
41            message: None,
42            reason: ExpectedReason::Any,
43        }
44    }
45
46    /// Expects the denial body to contain this exact message.
47    #[must_use]
48    pub fn with_message(mut self, message: impl Into<String>) -> Self {
49        self.message = Some(message.into());
50        self
51    }
52
53    /// Expects the denial body to contain this exact reason code.
54    #[must_use]
55    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
56        self.reason = ExpectedReason::Some(reason.into());
57        self
58    }
59
60    /// Expects the denial body to omit its reason code.
61    #[must_use]
62    pub fn without_reason(mut self) -> Self {
63        self.reason = ExpectedReason::None;
64        self
65    }
66}
67
68/// Parses and asserts an axum denial response.
69///
70/// The parsed body is returned so tests can make additional application-specific
71/// assertions without reparsing the response.
72///
73/// # Errors
74///
75/// Returns [`DenialAssertError`] if the HTTP status, JSON body, denial category,
76/// message, or reason does not match the expectation.
77pub async fn assert_denial_response(
78    response: Response<Body>,
79    expected: ExpectedDenial,
80) -> Result<DenialBody, DenialAssertError> {
81    let status = response.status();
82    if status != expected.status {
83        return Err(DenialAssertError::Status {
84            expected: expected.status,
85            actual: status,
86        });
87    }
88
89    let bytes = to_bytes(response.into_body(), usize::MAX)
90        .await
91        .map_err(DenialAssertError::Body)?;
92    let value =
93        serde_json::from_slice::<serde_json::Value>(&bytes).map_err(DenialAssertError::Json)?;
94    let fields = denial_body_fields(&value)?;
95    if fields != DENIAL_BODY_FIELDS {
96        return Err(DenialAssertError::Fields { actual: fields });
97    }
98    let body = serde_json::from_value::<DenialBody>(value).map_err(DenialAssertError::Json)?;
99    if body.error != expected.error {
100        return Err(DenialAssertError::Error {
101            expected: expected.error,
102            actual: body.error,
103        });
104    }
105    if let Some(expected_message) = expected.message
106        && body.message != expected_message
107    {
108        return Err(DenialAssertError::Message {
109            expected: expected_message,
110            actual: body.message,
111        });
112    }
113    match expected.reason {
114        ExpectedReason::None if body.reason.is_some() => {
115            return Err(DenialAssertError::Reason {
116                expected: None,
117                actual: body.reason,
118            });
119        }
120        ExpectedReason::Some(expected_reason)
121            if body.reason.as_deref() != Some(&expected_reason) =>
122        {
123            return Err(DenialAssertError::Reason {
124                expected: Some(expected_reason),
125                actual: body.reason,
126            });
127        }
128        ExpectedReason::Any | ExpectedReason::None | ExpectedReason::Some(_) => {}
129    }
130    Ok(body)
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134enum ExpectedReason {
135    Any,
136    None,
137    Some(String),
138}
139
140fn denial_body_fields(value: &serde_json::Value) -> Result<Vec<String>, DenialAssertError> {
141    let serde_json::Value::Object(object) = value else {
142        return Err(DenialAssertError::Shape);
143    };
144    let mut fields = object.keys().cloned().collect::<Vec<_>>();
145    fields.sort();
146    Ok(fields)
147}
148
149/// Error returned by [`assert_denial_response`].
150#[derive(Debug, Error)]
151pub enum DenialAssertError {
152    /// The HTTP status did not match.
153    #[error("expected denial status {expected}, got {actual}")]
154    Status {
155        /// Expected HTTP status.
156        expected: StatusCode,
157        /// Actual HTTP status.
158        actual: StatusCode,
159    },
160    /// The response body could not be collected.
161    #[error("failed to read denial body")]
162    Body(#[source] axum::Error),
163    /// The response body was not a `DenialBody` JSON payload.
164    #[error("failed to decode denial body")]
165    Json(#[source] serde_json::Error),
166    /// The response body was not a JSON object.
167    #[error("denial body must be a JSON object")]
168    Shape,
169    /// The response body contained unexpected fields.
170    #[error("expected denial fields [\"error\", \"message\", \"reason\"], got {actual:?}")]
171    Fields {
172        /// Actual response fields.
173        actual: Vec<String>,
174    },
175    /// The denial category did not match.
176    #[error("expected denial error {expected:?}, got {actual:?}")]
177    Error {
178        /// Expected denial category.
179        expected: DenialError,
180        /// Actual denial category.
181        actual: DenialError,
182    },
183    /// The denial message did not match.
184    #[error("expected denial message {expected:?}, got {actual:?}")]
185    Message {
186        /// Expected denial message.
187        expected: String,
188        /// Actual denial message.
189        actual: String,
190    },
191    /// The denial reason did not match.
192    #[error("expected denial reason {expected:?}, got {actual:?}")]
193    Reason {
194        /// Expected denial reason.
195        expected: Option<String>,
196        /// Actual denial reason.
197        actual: Option<String>,
198    },
199}