use std::fmt;
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) const OPAQUE_SERVER_ERROR: &str = "internal server error";
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) const DISCLOSE_ENV: &str = "RUSTANGO_DISCLOSE_ERRORS";
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) fn disclose_server_errors() -> bool {
matches!(
std::env::var(DISCLOSE_ENV)
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg_attr(
not(any(feature = "admin", feature = "tenancy", feature = "_tera")),
allow(dead_code)
)]
pub(crate) fn debug_details_enabled() -> bool {
if let Ok(raw) = std::env::var("RUSTANGO_TEMPLATE_DEBUG") {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => return true,
"0" | "false" | "no" | "off" => return false,
_ => {}
}
}
let env = std::env::var("RUSTANGO_ENV").unwrap_or_default();
!matches!(
env.trim().to_ascii_lowercase().as_str(),
"prod" | "production"
)
}
#[cfg(test)]
pub(crate) mod test_env {
pub(crate) fn lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
pub(crate) fn with<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let prev = std::env::var(key).ok();
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
f();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) fn server_error_body(context: &str, e: &dyn fmt::Display) -> String {
tracing::error!(target: "rustango::error", context, error = %e, "server error");
if disclose_server_errors() {
e.to_string()
} else {
OPAQUE_SERVER_ERROR.to_owned()
}
}
#[cfg(feature = "_axum")]
pub fn server_error(e: impl fmt::Display) -> (axum::http::StatusCode, String) {
let body = server_error_body("handler", &e);
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, body)
}
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) fn client_error_body(
context: &str,
e: &dyn fmt::Display,
client_caused: bool,
) -> String {
if client_caused {
tracing::warn!(target: "rustango::error", context, error = %e, "rejected request");
} else {
tracing::error!(target: "rustango::error", context, error = %e, "server error");
}
if disclose_server_errors() {
e.to_string()
} else {
OPAQUE_CLIENT_ERROR.to_owned()
}
}
#[cfg_attr(not(any(feature = "admin", feature = "tenancy")), allow(dead_code))]
pub(crate) const OPAQUE_CLIENT_ERROR: &str = "request rejected";
#[derive(Debug)]
#[non_exhaustive]
pub enum RustangoError {
Sql(crate::sql::ExecError),
Migrate(crate::migrate::MigrateError),
#[cfg(feature = "forms")]
Forms(crate::forms::FormErrors),
#[cfg(feature = "cache")]
Cache(crate::cache::CacheError),
#[cfg(feature = "email")]
Mail(crate::email::MailError),
#[cfg(feature = "storage")]
Storage(crate::storage::StorageError),
#[cfg(feature = "tenancy")]
Auth(crate::tenancy::auth_backends::AuthError),
#[cfg(feature = "tenancy")]
JwtIssue(crate::tenancy::jwt_lifecycle::JwtIssueError),
#[cfg(feature = "passwords")]
Password(crate::passwords::PasswordError),
#[cfg(feature = "api_keys")]
ApiKey(crate::api_keys::ApiKeyError),
#[cfg(feature = "signed_url")]
SignedUrl(crate::signed_url::SignedUrlError),
#[cfg(feature = "auth_flows")]
AuthFlow(crate::auth_flows::AuthFlowError),
#[cfg(feature = "tenancy")]
BulkAction(crate::bulk_actions::BulkActionError),
#[cfg(feature = "admin")]
IpFilter(crate::ip_filter::IpFilterError),
#[cfg(feature = "jobs")]
Job(crate::jobs::JobError),
Fixture(crate::fixtures::FixtureError),
I18n(crate::i18n::I18nError),
Env(crate::env::EnvError),
#[cfg(feature = "secrets")]
Secrets(crate::secrets::SecretsError),
Io(std::io::Error),
Serde(serde_json::Error),
Other(Box<dyn std::error::Error + Send + Sync + 'static>),
}
impl RustangoError {
pub fn other_from<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
Self::Other(Box::new(e))
}
pub fn other(msg: impl Into<String>) -> Self {
let s: Box<dyn std::error::Error + Send + Sync + 'static> =
Box::<dyn std::error::Error + Send + Sync + 'static>::from(msg.into());
Self::Other(s)
}
}
impl fmt::Display for RustangoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Sql(e) => write!(f, "sql: {e}"),
Self::Migrate(e) => write!(f, "migrate: {e}"),
#[cfg(feature = "forms")]
Self::Forms(e) => write!(f, "form: {e}"),
#[cfg(feature = "cache")]
Self::Cache(e) => write!(f, "cache: {e}"),
#[cfg(feature = "email")]
Self::Mail(e) => write!(f, "mail: {e}"),
#[cfg(feature = "storage")]
Self::Storage(e) => write!(f, "storage: {e}"),
#[cfg(feature = "tenancy")]
Self::Auth(e) => write!(f, "auth: {e}"),
#[cfg(feature = "tenancy")]
Self::JwtIssue(e) => write!(f, "jwt: {e}"),
#[cfg(feature = "passwords")]
Self::Password(e) => write!(f, "password: {e}"),
#[cfg(feature = "api_keys")]
Self::ApiKey(e) => write!(f, "api_key: {e}"),
#[cfg(feature = "signed_url")]
Self::SignedUrl(e) => write!(f, "signed_url: {e}"),
#[cfg(feature = "auth_flows")]
Self::AuthFlow(e) => write!(f, "auth_flow: {e}"),
#[cfg(feature = "tenancy")]
Self::BulkAction(e) => write!(f, "bulk_action: {e}"),
#[cfg(feature = "admin")]
Self::IpFilter(e) => write!(f, "ip_filter: {e}"),
#[cfg(feature = "jobs")]
Self::Job(e) => write!(f, "job: {e}"),
Self::Fixture(e) => write!(f, "fixture: {e}"),
Self::I18n(e) => write!(f, "i18n: {e}"),
Self::Env(e) => write!(f, "env: {e}"),
#[cfg(feature = "secrets")]
Self::Secrets(e) => write!(f, "secrets: {e}"),
Self::Io(e) => write!(f, "io: {e}"),
Self::Serde(e) => write!(f, "serde: {e}"),
Self::Other(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for RustangoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Sql(e) => Some(e),
Self::Migrate(e) => Some(e),
#[cfg(feature = "forms")]
Self::Forms(e) => Some(e),
#[cfg(feature = "cache")]
Self::Cache(e) => Some(e),
#[cfg(feature = "email")]
Self::Mail(e) => Some(e),
#[cfg(feature = "storage")]
Self::Storage(e) => Some(e),
#[cfg(feature = "tenancy")]
Self::Auth(e) => Some(e),
#[cfg(feature = "tenancy")]
Self::JwtIssue(e) => Some(e),
#[cfg(feature = "passwords")]
Self::Password(e) => Some(e),
#[cfg(feature = "api_keys")]
Self::ApiKey(e) => Some(e),
#[cfg(feature = "signed_url")]
Self::SignedUrl(e) => Some(e),
#[cfg(feature = "auth_flows")]
Self::AuthFlow(e) => Some(e),
#[cfg(feature = "tenancy")]
Self::BulkAction(e) => Some(e),
#[cfg(feature = "admin")]
Self::IpFilter(e) => Some(e),
#[cfg(feature = "jobs")]
Self::Job(e) => Some(e),
Self::Fixture(e) => Some(e),
Self::I18n(e) => Some(e),
Self::Env(e) => Some(e),
#[cfg(feature = "secrets")]
Self::Secrets(e) => Some(e),
Self::Io(e) => Some(e),
Self::Serde(e) => Some(e),
Self::Other(e) => Some(&**e),
}
}
}
pub type RustangoResult<T> = Result<T, RustangoError>;
impl From<crate::sql::ExecError> for RustangoError {
fn from(e: crate::sql::ExecError) -> Self {
Self::Sql(e)
}
}
impl From<crate::migrate::MigrateError> for RustangoError {
fn from(e: crate::migrate::MigrateError) -> Self {
Self::Migrate(e)
}
}
#[cfg(feature = "forms")]
impl From<crate::forms::FormErrors> for RustangoError {
fn from(e: crate::forms::FormErrors) -> Self {
Self::Forms(e)
}
}
#[cfg(feature = "cache")]
impl From<crate::cache::CacheError> for RustangoError {
fn from(e: crate::cache::CacheError) -> Self {
Self::Cache(e)
}
}
#[cfg(feature = "email")]
impl From<crate::email::MailError> for RustangoError {
fn from(e: crate::email::MailError) -> Self {
Self::Mail(e)
}
}
#[cfg(feature = "storage")]
impl From<crate::storage::StorageError> for RustangoError {
fn from(e: crate::storage::StorageError) -> Self {
Self::Storage(e)
}
}
#[cfg(feature = "tenancy")]
impl From<crate::tenancy::auth_backends::AuthError> for RustangoError {
fn from(e: crate::tenancy::auth_backends::AuthError) -> Self {
Self::Auth(e)
}
}
#[cfg(feature = "tenancy")]
impl From<crate::tenancy::jwt_lifecycle::JwtIssueError> for RustangoError {
fn from(e: crate::tenancy::jwt_lifecycle::JwtIssueError) -> Self {
Self::JwtIssue(e)
}
}
#[cfg(feature = "passwords")]
impl From<crate::passwords::PasswordError> for RustangoError {
fn from(e: crate::passwords::PasswordError) -> Self {
Self::Password(e)
}
}
#[cfg(feature = "api_keys")]
impl From<crate::api_keys::ApiKeyError> for RustangoError {
fn from(e: crate::api_keys::ApiKeyError) -> Self {
Self::ApiKey(e)
}
}
#[cfg(feature = "signed_url")]
impl From<crate::signed_url::SignedUrlError> for RustangoError {
fn from(e: crate::signed_url::SignedUrlError) -> Self {
Self::SignedUrl(e)
}
}
#[cfg(feature = "auth_flows")]
impl From<crate::auth_flows::AuthFlowError> for RustangoError {
fn from(e: crate::auth_flows::AuthFlowError) -> Self {
Self::AuthFlow(e)
}
}
#[cfg(feature = "tenancy")]
impl From<crate::bulk_actions::BulkActionError> for RustangoError {
fn from(e: crate::bulk_actions::BulkActionError) -> Self {
Self::BulkAction(e)
}
}
#[cfg(feature = "admin")]
impl From<crate::ip_filter::IpFilterError> for RustangoError {
fn from(e: crate::ip_filter::IpFilterError) -> Self {
Self::IpFilter(e)
}
}
#[cfg(feature = "jobs")]
impl From<crate::jobs::JobError> for RustangoError {
fn from(e: crate::jobs::JobError) -> Self {
Self::Job(e)
}
}
impl From<crate::fixtures::FixtureError> for RustangoError {
fn from(e: crate::fixtures::FixtureError) -> Self {
Self::Fixture(e)
}
}
impl From<crate::i18n::I18nError> for RustangoError {
fn from(e: crate::i18n::I18nError) -> Self {
Self::I18n(e)
}
}
impl From<crate::env::EnvError> for RustangoError {
fn from(e: crate::env::EnvError) -> Self {
Self::Env(e)
}
}
#[cfg(feature = "secrets")]
impl From<crate::secrets::SecretsError> for RustangoError {
fn from(e: crate::secrets::SecretsError) -> Self {
Self::Secrets(e)
}
}
impl From<std::io::Error> for RustangoError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for RustangoError {
fn from(e: serde_json::Error) -> Self {
Self::Serde(e)
}
}
#[cfg(feature = "admin")]
mod into_response {
use super::RustangoError;
use crate::api_errors::ApiError;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
impl IntoResponse for RustangoError {
fn into_response(self) -> Response {
#[cfg(feature = "tenancy")]
if let RustangoError::Auth(crate::tenancy::auth_backends::AuthError::Refused(r)) = self
{
return r.into_response();
}
map_to_api_error(self).into_response()
}
}
fn server_fault(status: StatusCode, err: &RustangoError) -> ApiError {
let code = if status == StatusCode::SERVICE_UNAVAILABLE {
"service_unavailable"
} else {
"internal_error"
};
ApiError::new(status, code, super::server_error_body("RustangoError", err))
}
fn map_to_api_error(err: RustangoError) -> ApiError {
let msg = err.to_string();
match err {
#[cfg(feature = "tenancy")]
RustangoError::Auth(
crate::tenancy::auth_backends::AuthError::Database(_)
| crate::tenancy::auth_backends::AuthError::Exec(_),
) => server_fault(StatusCode::INTERNAL_SERVER_ERROR, &err),
#[cfg(feature = "auth_flows")]
RustangoError::AuthFlow(crate::auth_flows::AuthFlowError::Database(_)) => {
server_fault(StatusCode::INTERNAL_SERVER_ERROR, &err)
}
#[cfg(feature = "tenancy")]
RustangoError::BulkAction(crate::bulk_actions::BulkActionError::Database(_)) => {
server_fault(StatusCode::INTERNAL_SERVER_ERROR, &err)
}
#[cfg(feature = "passwords")]
RustangoError::Password(crate::passwords::PasswordError::Busy) => {
server_fault(StatusCode::SERVICE_UNAVAILABLE, &err)
}
#[cfg(feature = "api_keys")]
RustangoError::ApiKey(crate::api_keys::ApiKeyError::Busy) => {
server_fault(StatusCode::SERVICE_UNAVAILABLE, &err)
}
#[cfg(feature = "forms")]
RustangoError::Forms(_) => ApiError::validation(msg),
#[cfg(feature = "auth_flows")]
RustangoError::AuthFlow(_) => ApiError::bad_request(msg),
#[cfg(feature = "signed_url")]
RustangoError::SignedUrl(_) => ApiError::bad_request(msg),
#[cfg(feature = "tenancy")]
RustangoError::Auth(_) => ApiError::unauthorized(msg),
#[cfg(feature = "tenancy")]
RustangoError::BulkAction(_) => ApiError::bad_request(msg),
#[cfg(feature = "admin")]
RustangoError::IpFilter(_) => ApiError::bad_request(msg),
other => server_fault(StatusCode::INTERNAL_SERVER_ERROR, &other),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "tenancy")]
#[test]
fn a_refused_login_keeps_its_status_and_retry_after() {
use crate::login_throttle::LoginRefused;
for (refused, status, retry) in [
(
LoginRefused::Throttled {
retry_after_secs: 7,
},
StatusCode::TOO_MANY_REQUESTS,
"7",
),
(LoginRefused::Busy, StatusCode::SERVICE_UNAVAILABLE, "1"),
] {
let err: RustangoError =
crate::tenancy::auth_backends::AuthError::Refused(refused).into();
let resp = err.into_response();
assert_eq!(resp.status(), status, "{refused:?}");
let got = resp.headers().get(axum::http::header::RETRY_AFTER);
assert_eq!(
got.and_then(|v| v.to_str().ok()),
Some(retry),
"{refused:?}"
);
}
}
#[test]
fn server_faults_are_5xx_and_opaque() {
const LEAK: &str = "pg-prod-01.internal:5432 STRIPE_SECRET_KEY";
let mut cases: Vec<(RustangoError, StatusCode)> = vec![(
crate::env::EnvError::Missing("STRIPE_SECRET_KEY".into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
)];
#[cfg(feature = "tenancy")]
cases.extend([
(
crate::tenancy::auth_backends::AuthError::Database(sqlx::Error::Protocol(
LEAK.into(),
))
.into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
crate::tenancy::auth_backends::AuthError::Exec(crate::sql::ExecError::Driver(
sqlx::Error::Protocol(LEAK.into()),
))
.into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
crate::bulk_actions::BulkActionError::Database(LEAK.into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
crate::tenancy::jwt_lifecycle::JwtIssueError::ReservedClaim(LEAK.into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
]);
#[cfg(feature = "auth_flows")]
cases.push((
crate::auth_flows::AuthFlowError::Database(LEAK.into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
));
#[cfg(feature = "passwords")]
cases.extend([
(
crate::passwords::PasswordError::Hash(LEAK.into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
crate::passwords::PasswordError::Busy.into(),
StatusCode::SERVICE_UNAVAILABLE,
),
]);
#[cfg(feature = "api_keys")]
cases.extend([
(
crate::api_keys::ApiKeyError::Hash(LEAK.into()).into(),
StatusCode::INTERNAL_SERVER_ERROR,
),
(
crate::api_keys::ApiKeyError::Busy.into(),
StatusCode::SERVICE_UNAVAILABLE,
),
]);
let _g = crate::error::test_env::lock();
crate::error::test_env::with(crate::error::DISCLOSE_ENV, None, || {
for (err, want) in cases {
let what = format!("{err:?}");
let api = map_to_api_error(err);
assert_eq!(api.status, want, "{what}");
for marker in ["pg-prod-01", "5432", "STRIPE_SECRET_KEY"] {
assert!(!api.message.contains(marker), "{what}: {}", api.message);
}
}
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[test]
fn from_io_error_works() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let e: RustangoError = io.into();
assert!(matches!(e, RustangoError::Io(_)));
assert!(e.to_string().contains("io: "));
}
#[test]
fn from_serde_error_works() {
let res: Result<i64, _> = serde_json::from_str("not-json");
let serde_err = res.unwrap_err();
let e: RustangoError = serde_err.into();
assert!(matches!(e, RustangoError::Serde(_)));
}
#[cfg(feature = "cache")]
#[test]
fn from_cache_error_works() {
let e: RustangoError = crate::cache::CacheError::Connection("nope".into()).into();
assert!(matches!(e, RustangoError::Cache(_)));
}
#[test]
fn other_from_wraps_external() {
#[derive(Debug)]
struct Custom;
impl std::fmt::Display for Custom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "custom")
}
}
impl std::error::Error for Custom {}
let e = RustangoError::other_from(Custom);
assert!(matches!(e, RustangoError::Other(_)));
assert!(e.to_string().contains("custom"));
}
#[test]
fn other_with_string_works() {
let e = RustangoError::other("ad-hoc message");
assert!(matches!(e, RustangoError::Other(_)));
assert!(e.to_string().contains("ad-hoc message"));
}
#[test]
fn source_chain_for_serde() {
let res: Result<i64, _> = serde_json::from_str("not-json");
let e: RustangoError = res.unwrap_err().into();
assert!(
e.source().is_some(),
"RustangoError should expose source for chained errors"
);
}
#[test]
fn rustango_result_alias_works() {
fn returns() -> RustangoResult<i32> {
Ok(42)
}
assert_eq!(returns().unwrap(), 42);
}
#[test]
fn question_mark_propagates_io_error() {
fn inner() -> RustangoResult<()> {
std::fs::read_to_string("/no/such/file/exists/at/all/promise/12345").map(|_| ())?;
Ok(())
}
let r = inner();
assert!(matches!(r, Err(RustangoError::Io(_))));
}
#[cfg(feature = "admin")]
#[test]
fn into_response_maps_io_to_500() {
use axum::response::IntoResponse;
let io = std::io::Error::new(std::io::ErrorKind::Other, "boom");
let e: RustangoError = io.into();
let r = e.into_response();
assert_eq!(r.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(all(feature = "admin", feature = "forms"))]
#[test]
fn into_response_maps_form_errors_to_422() {
use axum::response::IntoResponse;
let mut errs = crate::forms::FormErrors::default();
errs.add("title", "required");
let e: RustangoError = errs.into();
let r = e.into_response();
assert_eq!(r.status(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[cfg(all(feature = "admin", feature = "tenancy"))]
#[test]
fn into_response_maps_jwt_to_500() {
use axum::response::IntoResponse;
let jwt_err = crate::tenancy::jwt_lifecycle::JwtIssueError::ReservedClaim("sub".into());
let e: RustangoError = jwt_err.into();
let r = e.into_response();
assert_eq!(r.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
const DRIVER_ERROR: &str = "error returned from database: relation \
\"tenant_billing_accounts\" violates unique constraint \
\"uq_billing_stripe_customer\" (db=pg-prod-01.internal:5432)";
fn assert_withholds(body: &str, what: &str) {
for marker in [
"tenant_billing_accounts",
"uq_billing_stripe_customer",
"pg-prod-01.internal",
"5432",
] {
assert!(!body.contains(marker), "{what} leaked `{marker}`: {body}");
}
}
#[test]
fn server_error_body_withholds_by_default_with_no_env_set() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, None, || {
test_env::with("RUSTANGO_TEMPLATE_DEBUG", None, || {
test_env::with("RUSTANGO_ENV", None, || {
let body = server_error_body("test", &DRIVER_ERROR);
assert_withholds(&body, "default 500 body");
assert_eq!(body, OPAQUE_SERVER_ERROR);
});
});
});
}
#[cfg(feature = "_axum")]
#[test]
fn public_server_error_withholds_the_cause() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, None, || {
let (status, body) = server_error(DRIVER_ERROR);
assert_eq!(status, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, OPAQUE_SERVER_ERROR);
});
}
#[test]
fn the_template_debug_tier_does_not_open_the_5xx_body() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, None, || {
test_env::with("RUSTANGO_TEMPLATE_DEBUG", Some("1"), || {
test_env::with("RUSTANGO_ENV", None, || {
assert!(
debug_details_enabled(),
"precondition: the overlay tier is on in this state",
);
let body = server_error_body("test", &DRIVER_ERROR);
assert_withholds(&body, "body with the overlay tier on");
});
});
});
}
#[test]
fn server_error_body_withholds_driver_text_in_prod() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, None, || {
test_env::with("RUSTANGO_TEMPLATE_DEBUG", None, || {
test_env::with("RUSTANGO_ENV", Some("prod"), || {
let body = server_error_body("test", &DRIVER_ERROR);
assert_withholds(&body, "prod 500 body");
assert_eq!(body, OPAQUE_SERVER_ERROR);
});
});
});
}
#[test]
fn a_client_caused_4xx_does_not_claim_a_server_fault() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, None, || {
let body = client_error_body("test", &DRIVER_ERROR, true);
assert_withholds(&body, "default 400 body");
assert_eq!(body, OPAQUE_CLIENT_ERROR);
assert!(
!body.contains("internal server error"),
"a 400 must not report a server fault: {body}",
);
});
}
#[test]
fn an_explicit_opt_in_still_shows_the_cause() {
let _g = test_env::lock();
test_env::with(DISCLOSE_ENV, Some("1"), || {
test_env::with("RUSTANGO_TEMPLATE_DEBUG", None, || {
test_env::with("RUSTANGO_ENV", Some("prod"), || {
let body = server_error_body("test", &DRIVER_ERROR);
assert!(
body.contains("uq_billing_stripe_customer"),
"an explicit opt-in must still show the cause: {body}",
);
});
});
});
}
}