pub mod auth;
pub mod events;
pub mod executions;
pub mod extract;
pub mod health;
pub mod jobs;
pub mod repositories;
pub mod response;
pub mod snapshots;
pub mod targets;
#[cfg(test)]
mod secretos;
#[cfg(test)]
mod particion;
use axum::Router;
use axum::http::{HeaderValue, header};
use axum::routing::{get, post};
use serde::Serialize;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::api::response::Data;
use crate::auth::middleware;
use crate::engine::ErrorDelMotor;
use crate::error::AppError;
use crate::state::{AppState, AuthState};
async fn trazar(
peticion: axum::extract::Request,
siguiente: axum::middleware::Next,
) -> axum::response::Response {
let metodo = peticion.method().clone();
let ruta = peticion.uri().path().to_owned();
let respuesta = siguiente.run(peticion).await;
tracing::debug!(
%metodo,
%ruta,
estado = respuesta.status().as_u16(),
"petición atendida"
);
respuesta
}
pub fn api_router(state: AppState) -> Router {
let mut public: Router<AppState> = Router::new().route("/api/v1/health", get(health::health));
let router = match &state.auth {
Some(auth) => {
public = public
.route("/api/v1/auth/login", get(auth::login))
.route("/api/v1/auth/callback", get(auth::callback));
let negocio = Router::new()
.route("/api/v1/auth/logout", post(auth::logout))
.route("/api/v1/auth/session", get(auth::session))
.merge(repositories::router())
.merge(targets::router())
.merge(jobs::router())
.merge(executions::router())
.merge(snapshots::router())
.merge(events::router());
public.merge(protected_router(negocio, auth))
}
None => public
.merge(repositories::router())
.merge(targets::router())
.merge(jobs::router())
.merge(executions::router())
.merge(snapshots::router())
.merge(events::router()),
};
let hsts_value: Option<HeaderValue> = state
.config
.hsts_max_age
.and_then(|edad| HeaderValue::from_str(&format!("max-age={edad}")).ok());
router
.fallback(not_found)
.method_not_allowed_fallback(metodo_no_permitido)
.layer(axum::middleware::from_fn(trazar))
.layer(SetResponseHeaderLayer::overriding(
header::STRICT_TRANSPORT_SECURITY,
move |_respuesta: &axum::response::Response| hsts_value.clone(),
))
.with_state(state)
}
pub fn protected_router<S>(routes: Router<S>, auth: &AuthState) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
routes
.method_not_allowed_fallback(metodo_no_permitido)
.route_layer(axum::middleware::from_fn_with_state(
auth.clone(),
middleware::gate,
))
}
pub(crate) async fn not_found() -> AppError {
AppError::NotFound("la ruta solicitada no existe".to_owned())
}
pub(crate) async fn metodo_no_permitido() -> AppError {
AppError::MethodNotAllowed("el método no está permitido en esta ruta".to_owned())
}
pub(crate) const TAREA_QUE_NO_ESTA: &str = "no existe ninguna tarea con ese identificador";
pub(crate) fn del_motor(error: ErrorDelMotor) -> AppError {
let motivo = error.to_string();
match error {
ErrorDelMotor::TareaQueNoEsta { .. } => AppError::NotFound(TAREA_QUE_NO_ESTA.to_owned()),
ErrorDelMotor::YaTieneUnaEnCurso { .. } => AppError::Conflict(
"la tarea ya tiene una ejecución en curso: no se encola una segunda".to_owned(),
),
ErrorDelMotor::MotorParado
| ErrorDelMotor::ColaDelRepositorioLlena { .. }
| ErrorDelMotor::SinQuienAtiendaLaCola { .. } => AppError::ServiceUnavailable(motivo),
ErrorDelMotor::Base { .. }
| ErrorDelMotor::TrabajoInterrumpido { .. }
| ErrorDelMotor::SinRuntime => AppError::Other(motivo),
ErrorDelMotor::MotorDeContenedores { .. } | ErrorDelMotor::Repositorio { .. } => {
AppError::BadGateway(motivo)
}
}
}
const PASSPHRASE_QUE_NO_ABRE: &str = "el campo `passphrase` no abre el repositorio que ya está en el destino: \
revisa la passphrase, y que el repositorio del destino siga entero";
pub(crate) fn del_motor_en_el_alta(error: ErrorDelMotor) -> AppError {
if let ErrorDelMotor::Repositorio { causa, .. } = &error {
match causa {
crate::rustic::Error::PassphraseNoAbre { .. } => {
return AppError::InvalidInput(PASSPHRASE_QUE_NO_ABRE.to_owned());
}
crate::rustic::Error::AccesoInvalido { campo } => {
return AppError::InvalidInput(format!("el campo `{campo}` no puede ir vacío"));
}
_ => {}
}
}
del_motor(error)
}
pub(crate) fn contenido<T: Serialize>(vista: &T) -> Result<Data, AppError> {
serde_json::to_value(vista)
.map(Data::Some)
.map_err(|error| AppError::Other(format!("la respuesta no se pudo serializar: {error}")))
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use axum::Router;
use axum::http::{StatusCode, header};
use super::{api_router, protected_router};
use crate::api::response::{ApiResponse, Data};
use crate::test_support::{
auth_oidc, body_json, body_text, cookie_de_sesion, estado_con_base, estado_desarrollo,
estado_oidc, get, get_with_cookie, header_text, lookup_from, post, post_with_cookie,
router_desarrollo, router_oidc,
};
use crate::config::Config;
async fn negocio() -> ApiResponse {
ApiResponse::new(StatusCode::OK, "atendida", Data::None)
}
#[tokio::test]
async fn los_estados_de_prueba_no_heredan_el_puerto_por_defecto() {
for (modo, estado) in [
("desarrollo", estado_desarrollo().await),
("Oidc", estado_oidc().await),
] {
assert_eq!(
estado.config.bind_address.port(),
0,
"el estado de {modo} escucha en {} y no en un puerto efímero",
estado.config.bind_address
);
}
}
const MASTER_KEY_HSTS: &str = "clave-maestra-para-hsts-00000000000000000000000000000000000000";
#[tokio::test]
async fn sin_hsts_ninguna_respuesta_lleva_la_cabecera() {
let respuesta = get(router_desarrollo().await, "/api/v1/health").await;
assert_eq!(
header_text(&respuesta, header::STRICT_TRANSPORT_SECURITY),
None,
"sin configurar la HSTS no debe haber cabecera"
);
}
#[tokio::test]
async fn con_hsts_toda_respuesta_lleva_la_cabecera() {
let config = Config::from_lookup(&lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY_HSTS),
("KESTO_HSTS_MAX_AGE", "31536000"),
]))
.expect("la configuración con HSTS es válida");
let router = api_router(estado_con_base(config).await);
for uri in ["/api/v1/health", "/api/v1/no-existe"] {
let respuesta = get(router.clone(), uri).await;
assert_eq!(
header_text(&respuesta, header::STRICT_TRANSPORT_SECURITY).as_deref(),
Some("max-age=31536000"),
"la respuesta de {uri} debe llevar la HSTS"
);
}
}
#[tokio::test]
async fn en_modo_oidc_el_flujo_protegido_no_se_atiende_sin_sesion() {
let respuesta = get(router_oidc().await, "/api/v1/auth/session").await;
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()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 401 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn en_desarrollo_el_flujo_no_esta_montado_y_contesta_404() {
let respuesta = get(router_desarrollo().await, "/api/v1/auth/session").await;
assert_ne!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"sin proveedor no hay capa de autenticación que exigir"
);
assert_eq!(respuesta.status(), StatusCode::NOT_FOUND);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 404 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 404 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 404);
}
#[tokio::test]
async fn la_salud_sigue_siendo_publica_con_la_autenticacion_encendida() {
let respuesta = get(router_oidc().await, "/api/v1/health").await;
assert_eq!(respuesta.status(), StatusCode::OK);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["data"]["auth_mode"], "oidc");
}
#[tokio::test]
async fn la_superficie_publica_es_una_lista_blanca() {
for ruta in [
"/api/v1/health",
"/api/v1/auth/login",
"/api/v1/auth/callback",
] {
let respuesta = get(router_oidc().await, ruta).await;
assert_ne!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"la ruta pública {ruta} exige sesión"
);
}
let eventos = protected_router(
Router::new().route("/api/v1/events", axum::routing::get(negocio)),
&auth_oidc().await,
);
let sin_cookie = get(eventos.clone(), "/api/v1/events").await;
assert_eq!(
sin_cookie.status(),
StatusCode::UNAUTHORIZED,
"el SSE no puede quedar del lado público"
);
let con_cookie = get_with_cookie(eventos, "/api/v1/events", &cookie_de_sesion()).await;
assert_eq!(
con_cookie.status(),
StatusCode::OK,
"con sesión el SSE atendido debe pasar"
);
}
#[tokio::test]
async fn las_rutas_publicas_no_pasan_por_el_gate_y_la_superficie_de_negocio_si() {
let salud = get(router_oidc().await, "/api/v1/health").await;
assert_eq!(salud.status(), StatusCode::OK);
assert_ne!(salud.status(), StatusCode::UNAUTHORIZED);
let negocio_router = protected_router(
Router::new().route("/api/v1/targets", axum::routing::get(negocio)),
&auth_oidc().await,
);
let sin_cookie = get(negocio_router.clone(), "/api/v1/targets").await;
assert_eq!(sin_cookie.status(), StatusCode::UNAUTHORIZED);
let con_cookie =
get_with_cookie(negocio_router, "/api/v1/targets", &cookie_de_sesion()).await;
assert_eq!(
con_cookie.status(),
StatusCode::OK,
"con sesión la superficie de negocio debe pasar"
);
}
#[tokio::test]
async fn un_metodo_no_declarado_sin_sesion_no_contesta_405() {
let respuesta = post(router_oidc().await, "/api/v1/auth/session").await;
assert_ne!(
respuesta.status(),
StatusCode::METHOD_NOT_ALLOWED,
"el 405 sin sesión revela a quien no ha entrado qué métodos existen"
);
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()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 401 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn un_metodo_no_declarado_con_sesion_contesta_405() {
let respuesta = post_with_cookie(
router_oidc().await,
"/api/v1/auth/session",
&cookie_de_sesion(),
)
.await;
assert_eq!(respuesta.status(), StatusCode::METHOD_NOT_ALLOWED);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 405 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 405 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 405);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 405 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn una_ruta_no_declarada_no_pasa_por_el_gate() {
let respuesta = get(router_oidc().await, "/api/v1/no-existe").await;
assert_ne!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"el 404 no pasa por el gate y no puede exigir sesión"
);
assert_eq!(respuesta.status(), StatusCode::NOT_FOUND);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 404 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 404 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 404);
}
#[tokio::test]
async fn el_401_no_es_una_redireccion_ni_un_cuerpo_vacio() {
let respuesta = get(router_oidc().await, "/api/v1/auth/session").await;
assert_eq!(respuesta.status(), StatusCode::UNAUTHORIZED);
assert!(
!respuesta.status().is_redirection(),
"el rechazo no puede ser un 3xx: la API contesta JSON"
);
assert!(
header_text(&respuesta, header::LOCATION).is_none(),
"el rechazo no puede traer Location"
);
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);
}
#[tokio::test]
async fn una_ruta_desconocida_contesta_404_con_envoltura() {
let respuesta = get(api_router(estado_desarrollo().await), "/api/v1/no-existe").await;
assert_eq!(respuesta.status(), StatusCode::NOT_FOUND);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 404 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 404 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 404);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 404 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn un_metodo_no_declarado_contesta_405_con_envoltura() {
let respuesta = post(api_router(estado_desarrollo().await), "/api/v1/health").await;
assert_eq!(respuesta.status(), StatusCode::METHOD_NOT_ALLOWED);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 405 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 405 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 405);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 405 debe traer un mensaje: {texto}"
);
}
#[tokio::test]
async fn el_servidor_devuelve_cuando_la_senal_de_cierre_se_resuelve() {
let (senal, espera) = tokio::sync::oneshot::channel::<()>();
let servidor = tokio::spawn(async move {
crate::serve(estado_desarrollo().await, async move {
let _ = espera.await;
})
.await
});
senal.send(()).expect("la señal de cierre debe entregarse");
let transcurrido = tokio::time::timeout(Duration::from_secs(5), servidor).await;
transcurrido
.expect("el servidor no devolvió al resolverse la señal de cierre")
.expect("la tarea del servidor no debe entrar en pánico")
.expect("el servidor debe cerrar sin error");
}
}