use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror::Error;
use crate::api::response::ErrorResponse;
use crate::config::ConfigError;
use crate::db::DbError;
const INTERNAL_MESSAGE: &str = "error interno del servidor";
const UNAVAILABLE_MESSAGE: &str =
"el servicio no está disponible para atender la petición ahora mismo";
#[derive(Debug, Error)]
pub enum AppError {
#[error("{0}")]
InvalidInput(String),
#[error("{0}")]
NotFound(String),
#[error("{0}")]
MethodNotAllowed(String),
#[error("{0}")]
Unauthorized(String),
#[error("{0}")]
UnprocessableEntity(String),
#[error("{0}")]
Conflict(String),
#[error("{0}")]
ServiceUnavailable(String),
#[error("{0}")]
Other(String),
#[error("{0}")]
BadGateway(String),
#[error(transparent)]
Database(#[from] DbError),
#[error(transparent)]
Config(#[from] ConfigError),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::InvalidInput(motivo) => {
tracing::debug!(motivo = %motivo, "petición rechazada");
(StatusCode::BAD_REQUEST, motivo)
}
Self::NotFound(motivo) => (StatusCode::NOT_FOUND, motivo),
Self::MethodNotAllowed(motivo) => (StatusCode::METHOD_NOT_ALLOWED, motivo),
Self::Unauthorized(motivo) => (StatusCode::UNAUTHORIZED, motivo),
Self::Conflict(motivo) => {
tracing::debug!(motivo = %motivo, "petición rechazada");
(StatusCode::CONFLICT, motivo)
}
Self::UnprocessableEntity(motivo) => {
tracing::debug!(motivo = %motivo, "petición rechazada");
(StatusCode::UNPROCESSABLE_ENTITY, motivo)
}
Self::ServiceUnavailable(causa) => {
tracing::error!(error = %causa, "el servicio no puede atender la petición");
(
StatusCode::SERVICE_UNAVAILABLE,
UNAVAILABLE_MESSAGE.to_owned(),
)
}
Self::Other(causa) => {
tracing::error!(error = %causa, "fallo interno al atender la petición");
(
StatusCode::INTERNAL_SERVER_ERROR,
INTERNAL_MESSAGE.to_owned(),
)
}
Self::BadGateway(causa) => {
tracing::error!(error = %causa, "un servicio de terceros no contestó");
(StatusCode::BAD_GATEWAY, INTERNAL_MESSAGE.to_owned())
}
Self::Database(causa) => traducir_base(causa),
Self::Config(error) => {
tracing::error!(error = %error, "error de configuración al atender la petición");
(
StatusCode::INTERNAL_SERVER_ERROR,
INTERNAL_MESSAGE.to_owned(),
)
}
};
ErrorResponse::new(status, message).into_response()
}
}
fn traducir_base(causa: DbError) -> (StatusCode, String) {
if let DbError::SecretoVacio { campo } = &causa {
tracing::debug!(campo = %campo, "petición rechazada");
return (
StatusCode::BAD_REQUEST,
format!("el campo `{campo}` no puede ir vacío"),
);
}
if let DbError::Consulta {
causa: sqlx_causa, ..
} = &causa
&& es_violacion_de_clave_foranea(sqlx_causa)
{
tracing::error!(
error = %causa,
"la petición contradice una relación existente: se violó una clave foránea"
);
return (
StatusCode::CONFLICT,
"la petición contradice el estado actual: algún recurso que referencia ya no existe"
.to_owned(),
);
}
tracing::error!(error = %causa, "la base de datos falló");
(
StatusCode::INTERNAL_SERVER_ERROR,
INTERNAL_MESSAGE.to_owned(),
)
}
fn es_violacion_de_clave_foranea(causa: &sqlx::Error) -> bool {
matches!(causa, sqlx::Error::Database(error) if error.is_foreign_key_violation())
}
#[cfg(test)]
mod tests {
use axum::http::StatusCode;
use axum::response::IntoResponse;
use super::AppError;
use crate::db::DbError;
use crate::db::entities::{AltaDeRepositorio, AltaDeTarea, Repositorios, Tareas};
use crate::secret::SecretString;
use crate::test_support::{base_con_clave, body_json, body_text, capturar_log};
const CREDENCIAL: &str = "AKIAIOSFODNN7EXAMPLE";
#[tokio::test]
async fn un_error_de_entrada_da_400_con_el_motivo() {
let motivo = "el repositorio ya existe";
let respuesta = AppError::InvalidInput(motivo.to_owned()).into_response();
assert_eq!(respuesta.status(), StatusCode::BAD_REQUEST);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 400);
assert!(
cuerpo["message"]
.as_str()
.unwrap_or_default()
.contains(motivo),
"el 400 debe explicar el motivo: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
}
#[tokio::test]
async fn un_error_interno_no_filtra_la_causa() {
let respuesta = AppError::Other(format!("credencial S3: {CREDENCIAL}")).into_response();
assert_eq!(respuesta.status(), StatusCode::INTERNAL_SERVER_ERROR);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(CREDENCIAL),
"el 500 publicó la credencial de la causa: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 500 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 500);
assert_eq!(
cuerpo["message"],
super::INTERNAL_MESSAGE,
"el 500 debe traer exactamente el mensaje genérico: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
}
#[tokio::test]
async fn una_peticion_sin_sesion_da_401_con_el_motivo() {
let motivo = "la cookie de sesión no es válida";
let respuesta = AppError::Unauthorized(motivo.to_owned()).into_response();
assert_eq!(respuesta.status(), StatusCode::UNAUTHORIZED);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 401 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 401 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 401);
assert!(
cuerpo["message"]
.as_str()
.unwrap_or_default()
.contains(motivo),
"el 401 debe explicar el motivo: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
}
#[tokio::test]
async fn un_502_no_filtra_la_causa() {
let respuesta = AppError::BadGateway(format!(
"el proveedor de identidad no contesta: {CREDENCIAL}"
))
.into_response();
assert_eq!(respuesta.status(), StatusCode::BAD_GATEWAY);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(CREDENCIAL),
"el 502 publicó el contenido de la causa: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 502 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 502);
assert_eq!(
cuerpo["message"],
super::INTERNAL_MESSAGE,
"el 502 debe traer el mismo mensaje genérico que el 500: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
}
#[tokio::test]
async fn un_error_de_base_no_filtra_la_causa() {
let respuesta = AppError::Database(crate::db::DbError::DirectorioAusente {
ruta: std::path::PathBuf::from(format!("/srv/{CREDENCIAL}/data/kesto.db")),
})
.into_response();
assert_eq!(respuesta.status(), StatusCode::INTERNAL_SERVER_ERROR);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(CREDENCIAL),
"el 500 publicó la ruta de la causa: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 500 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 500);
assert_eq!(
cuerpo["message"],
super::INTERNAL_MESSAGE,
"el fallo de base debe traer el mensaje genérico: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
}
const PASS_DISTINGUIBLE: &str = "passphrase-distinguible-00000000000000000000000001";
const CRON_DISTINGUIBLE: &str = "37 51 4 17 6 2";
#[tokio::test]
async fn un_secreto_vacio_da_400_con_el_campo_nombrado() {
let (_directorio, pool, clave) = base_con_clave().await;
let repositorios = Repositorios::nuevo(pool, clave);
let error = repositorios
.alta(AltaDeRepositorio {
name: "repositorio-con-la-clave-secreta-vacia".to_owned(),
endpoint: "https://s3.eu-west-1.amazonaws.com".to_owned(),
bucket: "kesto-pruebas-00000000000000000000000001".to_owned(),
region: "eu-west-1".to_owned(),
access_key_id: CREDENCIAL.to_owned(),
secret_access_key: SecretString::new(String::new()),
passphrase: SecretString::new(PASS_DISTINGUIBLE.to_owned()),
})
.await
.expect_err("el alta con la clave secreta vacía debe fallar en la capa de acceso");
assert!(
matches!(&error, DbError::SecretoVacio { campo } if *campo == "secret_access_key"),
"se esperaba el rechazo del secreto vacío nombrando el campo: {error}"
);
let respuesta = AppError::Database(error).into_response();
let status = respuesta.status();
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un error causado por la petición no puede seguir contestando 5xx"
);
assert_ne!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"el secreto vacío lo mandó la petición: no es un fallo del servidor"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 400);
assert!(
cuerpo["message"]
.as_str()
.unwrap_or_default()
.contains("secret_access_key"),
"el 400 debe nombrar el campo que llegó mal: {cuerpo}"
);
}
#[tokio::test]
async fn una_clave_foranea_violada_da_409_sin_publicar_la_sentencia() {
let (_directorio, pool, _clave) = base_con_clave().await;
let (buffer, _guard) = capturar_log();
let tareas = Tareas::nuevo(pool);
let error = tareas
.alta(AltaDeTarea {
target_id: uuid::Uuid::new_v4(),
repository_id: uuid::Uuid::new_v4(),
cron_expression: CRON_DISTINGUIBLE.to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
})
.await
.expect_err(
"el alta con dos identificadores inexistentes debe violar la clave foránea",
);
assert!(
matches!(&error, DbError::Consulta { .. }),
"la violación de clave foránea llega como una consulta fallida: {error}"
);
let causa = error.to_string();
let respuesta = AppError::Database(error).into_response();
let status = respuesta.status();
assert_eq!(
status,
StatusCode::CONFLICT,
"la petición contradice el estado de la base: es un 409, no un 500"
);
let texto = body_text(respuesta).await;
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 409 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 409);
assert!(
cuerpo["message"].as_str().is_some(),
"el 409 debe explicar el conflicto: {cuerpo}"
);
for prohibido in ["INSERT", "constraint", "target_id", "repository_id"] {
assert!(
!texto.contains(prohibido),
"el 409 publicó «{prohibido}» del motor: {texto}"
);
}
let mensaje = cuerpo["message"].as_str().unwrap_or_default();
assert!(
!mensaje.contains(&causa),
"el 409 repitió el texto de la causa: {mensaje}"
);
assert!(
buffer.texto().contains("FOREIGN KEY constraint failed"),
"la causa debe quedar en el registro aunque no salga al cliente: {}",
buffer.texto()
);
}
#[tokio::test]
async fn una_consulta_que_falla_por_otra_causa_sigue_dando_500_generico() {
let (_directorio, pool, clave) = base_con_clave().await;
let (buffer, _guard) = capturar_log();
let repositorios = Repositorios::nuevo(pool.clone(), clave);
pool.close().await;
let error = repositorios
.listar()
.await
.expect_err("listar sobre un pool cerrado debe fallar");
let respuesta = AppError::Database(error).into_response();
let status = respuesta.status();
assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"un fallo de la base que no viene de la petición sigue siendo un 500"
);
let texto = body_text(respuesta).await;
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 500 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 500);
assert_eq!(
cuerpo["message"],
super::INTERNAL_MESSAGE,
"el fallo de base debe traer exactamente el mensaje genérico: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
assert!(
!buffer.texto().is_empty(),
"la causa debe viajar al registro aunque no al cliente"
);
}
#[tokio::test]
async fn los_4xx_no_filtran_ni_la_credencial_ni_la_ruta_de_la_base() {
{
let (directorio, pool, clave) = base_con_clave().await;
let ruta_de_la_base = directorio.ruta_de_la_base().display().to_string();
let repositorios = Repositorios::nuevo(pool, clave);
let error = repositorios
.alta(AltaDeRepositorio {
name: "repositorio-con-la-clave-secreta-vacia".to_owned(),
endpoint: "https://s3.eu-west-1.amazonaws.com".to_owned(),
bucket: "kesto-pruebas-00000000000000000000000001".to_owned(),
region: "eu-west-1".to_owned(),
access_key_id: CREDENCIAL.to_owned(),
secret_access_key: SecretString::new(String::new()),
passphrase: SecretString::new(PASS_DISTINGUIBLE.to_owned()),
})
.await
.expect_err("el alta con la clave secreta vacía debe fallar en la capa de acceso");
let causa = error.to_string();
let respuesta = AppError::Database(error).into_response();
assert_eq!(
respuesta.status(),
StatusCode::BAD_REQUEST,
"el secreto vacío es un error de la petición: 400"
);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(PASS_DISTINGUIBLE),
"el 400 publicó la passphrase de la petición: {texto}"
);
assert!(
!texto.contains(CREDENCIAL),
"el 400 publicó la credencial de la petición: {texto}"
);
assert!(
!texto.contains(&ruta_de_la_base),
"el 400 publicó la ruta del fichero de la base: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 400 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 400);
assert!(
!cuerpo["message"]
.as_str()
.unwrap_or_default()
.contains(&causa),
"el 400 copió el Display de la causa en el mensaje: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
assert_eq!(
cuerpo
.as_object()
.expect("el cuerpo de un error debe ser un objeto JSON")
.len(),
2,
"el 4xx debe traer solo status y message: {cuerpo}"
);
}
{
let (directorio, pool, _clave) = base_con_clave().await;
let ruta_de_la_base = directorio.ruta_de_la_base().display().to_string();
let tareas = Tareas::nuevo(pool);
let error = tareas
.alta(AltaDeTarea {
target_id: uuid::Uuid::new_v4(),
repository_id: uuid::Uuid::new_v4(),
cron_expression: CRON_DISTINGUIBLE.to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
})
.await
.expect_err(
"el alta con dos identificadores inexistentes debe violar la clave foránea",
);
let causa = error.to_string();
let respuesta = AppError::Database(error).into_response();
assert_eq!(
respuesta.status(),
StatusCode::CONFLICT,
"la clave foránea violada es un conflicto: 409"
);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(CRON_DISTINGUIBLE),
"el 409 publicó la cron de la petición: {texto}"
);
assert!(
!texto.contains(&ruta_de_la_base),
"el 409 publicó la ruta del fichero de la base: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 409 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 409);
assert!(
!cuerpo["message"]
.as_str()
.unwrap_or_default()
.contains(&causa),
"el 409 copió el Display de la causa en el mensaje: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {cuerpo}"
);
assert_eq!(
cuerpo
.as_object()
.expect("el cuerpo de un error debe ser un objeto JSON")
.len(),
2,
"el 4xx debe traer solo status y message: {cuerpo}"
);
}
}
}