mod config;
mod embedded;
mod metrics;
mod path;
mod render;
mod resolve;
mod theme;
use crate::config::{Config, LogFormat};
use crate::metrics::PageCounters;
use crate::path::NormalizedPath;
use crate::render::{RenderedPage, render_page, render_text_fallback};
use crate::resolve::{
code_from_path, es_alias_del_codigo_por_defecto, resolve_code_with_source, resolve_format,
};
use axum::Router;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::uri::{PathAndQuery, Uri};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::get;
use axum::{Json, serve};
use std::net::{Ipv4Addr, SocketAddr};
use tower_http::compression::CompressionLayer;
use tower_http::services::ServeDir;
use tower_http::trace::{DefaultOnFailure, TraceLayer};
use tracing_subscriber::EnvFilter;
const BIND_ADDRESS: Ipv4Addr = Ipv4Addr::UNSPECIFIED;
const FALLBACK_LOG_LEVEL: &str = "info";
const CORRELATION_HEADERS: [&str; 4] = [
"X-Request-Id",
"X-Trace-Id",
"X-Correlation-Id",
"X-Amzn-Trace-Id",
];
const SERVICE_ENDPOINTS: [&str; 6] = [
"/healthz",
"/health",
"/health/live",
"/live",
"/version",
"/metrics",
];
#[derive(Debug, Clone)]
struct AppState {
config: Config,
counters: PageCounters,
}
fn build_router(config: &Config) -> Router {
let state = AppState {
config: config.clone(),
counters: PageCounters::new(),
};
Router::new()
.route("/healthz", get(alive))
.route("/health", get(alive))
.route("/health/live", get(alive))
.route("/live", get(alive))
.route("/version", get(version))
.route("/metrics", get(metrics))
.fallback_service(
ServeDir::new(&config.content_dir).append_index_html_on_directories(false),
)
.layer(middleware::from_fn_with_state(state.clone(), error_pages))
.with_state(state)
.layer(middleware::from_fn(correlation))
.layer(CompressionLayer::new())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_env()?;
init_tracing(&config.log_level, config.log_format);
let address = SocketAddr::from((BIND_ADDRESS, config.port));
let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!(
%address,
content_dir = %config.content_dir.display(),
default_error_page = config.default_error_page,
"servidor de páginas de error para Traefik"
);
let trace_layer =
TraceLayer::new_for_http().on_failure(DefaultOnFailure::new().level(tracing::Level::DEBUG));
serve(listener, build_router(&config).layer(trace_layer)).await?;
Ok(())
}
fn init_tracing(log_level: &str, log_format: LogFormat) {
let filter = match EnvFilter::try_new(log_level) {
Ok(filter) => filter,
Err(err) => {
eprintln!("LOG_LEVEL no es un filtro válido ({err}); se usa `{FALLBACK_LOG_LEVEL}`");
EnvFilter::new(FALLBACK_LOG_LEVEL)
}
};
let installed = match log_format {
LogFormat::Text => tracing_subscriber::fmt().with_env_filter(filter).try_init(),
LogFormat::Json => tracing_subscriber::fmt()
.with_env_filter(filter)
.json()
.try_init(),
};
if let Err(err) = installed {
eprintln!("no se pudo instalar el registro: {err}");
}
}
async fn alive() -> StatusCode {
StatusCode::OK
}
async fn version() -> Json<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }))
}
async fn metrics(State(state): State<AppState>) -> Response {
let mut response = Response::new(Body::from(state.counters.render()));
insert_header(
response.headers_mut(),
header::CONTENT_TYPE.as_str(),
metrics::CONTENT_TYPE,
);
response
}
async fn correlation(request: Request, next: Next) -> Response {
let carried = correlation_headers(request.headers());
let mut response = next.run(request).await;
for (name, value) in carried {
response.headers_mut().insert(name, value);
}
declare_vary(response.headers_mut());
response
}
fn declare_vary(headers: &mut HeaderMap) {
let value = format!(
"Accept-Encoding, {}, X-Theme",
CORRELATION_HEADERS.join(", ")
);
append_header(headers, header::VARY.as_str(), &value);
}
fn correlation_headers(headers: &HeaderMap) -> Vec<(&'static str, HeaderValue)> {
CORRELATION_HEADERS
.iter()
.filter_map(|name| {
let value = headers.get(*name)?.clone();
Some((*name, value))
})
.collect()
}
struct Hints {
x_code: Option<String>,
content_type: Option<String>,
x_format: Option<String>,
accept: Option<String>,
x_theme: Option<String>,
}
impl Hints {
fn new(headers: &HeaderMap) -> Self {
let read = |name: &str| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
};
Self {
x_code: read("x-code"),
content_type: read(header::CONTENT_TYPE.as_str()),
x_format: read("x-format"),
accept: read(header::ACCEPT.as_str()),
x_theme: read("x-theme"),
}
}
}
async fn error_pages(State(state): State<AppState>, mut request: Request, next: Next) -> Response {
let is_head = request.method() == Method::HEAD;
let hints = Hints::new(request.headers());
let normalized = NormalizedPath::new(request.uri().path());
match normalized_uri(request.uri(), &normalized) {
Some(uri) => *request.uri_mut() = uri,
None => {
tracing::warn!(path = normalized.as_str(), "no se pudo reescribir la URI");
return error_response(&state, &normalized, &hints, is_head).await;
}
}
if is_error_page_path(normalized.as_str()) {
return error_response(&state, &normalized, &hints, is_head).await;
}
let response = next.run(request).await;
if is_service_endpoint(normalized.as_str()) {
return response;
}
let status = response.status();
if status.is_client_error() || status.is_server_error() {
return error_response(&state, &normalized, &hints, is_head).await;
}
response
}
fn normalized_uri(original: &Uri, normalized: &NormalizedPath) -> Option<Uri> {
let path = normalized.to_uri_path();
let path_and_query = match original.query() {
Some(query) => format!("{path}?{query}"),
None => path,
};
let mut parts = original.clone().into_parts();
parts.path_and_query = Some(PathAndQuery::from_maybe_shared(path_and_query).ok()?);
Uri::from_parts(parts).ok()
}
fn is_error_page_path(path: &str) -> bool {
let first_segment = path
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or_default();
first_segment.is_empty()
|| code_from_path(path).is_some()
|| es_alias_del_codigo_por_defecto(path)
}
fn is_service_endpoint(path: &str) -> bool {
SERVICE_ENDPOINTS.contains(&path)
}
async fn error_response(
state: &AppState,
path: &NormalizedPath,
hints: &Hints,
is_head: bool,
) -> Response {
let config = &state.config;
let (code, source) = resolve_code_with_source(
path.as_str(),
hints.x_code.as_deref(),
config.default_error_page,
);
let format = resolve_format(
path.as_str(),
hints.content_type.as_deref(),
hints.x_format.as_deref(),
hints.accept.as_deref(),
);
let theme = theme::resolve(hints.x_theme.as_deref(), config.theme);
state.counters.record(code, source);
let rendered = tokio::task::spawn_blocking({
let config = config.clone();
move || render_page(&config, code, format, theme)
})
.await;
match rendered {
Ok(Ok(page)) => page_response(page, is_head),
Ok(Err(err)) => {
tracing::warn!(
code,
error = %err,
"no se pudo obtener el HTML de la página de error: se responde texto"
);
page_response(render_text_fallback(code, config.retry_after), is_head)
}
Err(err) => {
tracing::error!(code, error = %err, "la tarea de render falló: se responde texto");
page_response(render_text_fallback(code, config.retry_after), is_head)
}
}
}
fn page_response(page: RenderedPage, is_head: bool) -> Response {
let body = if is_head { Vec::new() } else { page.body };
let mut response = Response::new(Body::from(body));
*response.status_mut() = page.status;
insert_header(
response.headers_mut(),
header::CONTENT_TYPE.as_str(),
page.content_type,
);
for (name, value) in page.headers {
insert_header(response.headers_mut(), name, &value);
}
response
}
fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) {
let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else {
return;
};
let Ok(value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(name, value);
}
fn append_header(headers: &mut HeaderMap, name: &str, value: &str) {
let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else {
return;
};
let Ok(value) = HeaderValue::from_str(value) else {
return;
};
headers.append(name, value);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::metrics::COUNTER_NAME;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use flate2::read::GzDecoder;
use http_body_util::BodyExt;
use std::io::Read;
use std::path::{Path, PathBuf};
use tower::ServiceExt;
fn fixtures_content_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/content")
}
fn fixtures_config() -> Config {
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
)])
.unwrap()
}
fn no_html_config() -> Config {
let content_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/no-html");
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
content_dir.to_string_lossy().into_owned(),
)])
.unwrap()
}
fn default_503_config() -> Config {
Config::from_env_iter([
(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
),
("DEFAULT_ERROR_PAGE".to_string(), "503".to_string()),
])
.unwrap()
}
fn fixtures_config_con_retry_after(value: &str) -> Config {
Config::from_env_iter([
(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
),
("RETRY_AFTER".to_string(), value.to_string()),
])
.expect("la configuración de fixtures debe construirse")
}
fn fixture_default_html() -> &'static str {
include_str!("../tests/fixtures/content/default.html")
}
fn fixture_servida(code: u16) -> String {
let rellena = crate::render::plantilla(fixture_default_html().as_bytes(), code);
let rellena =
String::from_utf8(rellena).expect("la plantilla del fixture, rellenada, es UTF-8");
assert!(
rellena.contains(&code.to_string()),
"red de la sustitución: el cuerpo servido del fixture tiene que \
**nombrar** su código (`{code}`), fue: {rellena}"
);
assert!(
!rellena.contains("{{"),
"red de la sustitución: no puede quedar ningún marcador sin rellenar, fue: {rellena}"
);
rellena
}
const EMBEDDED_DEFAULT_PAGE: &str = include_str!("../content/default.html");
fn pagina_del_tema(nombre: &str, code: u16) -> String {
let theme = crate::theme::theme_by_name(nombre)
.unwrap_or_else(|| panic!("el tema `{nombre}` tiene que existir"));
let rellena = crate::render::plantilla(theme.embedded.as_bytes(), code);
String::from_utf8(rellena).expect("la página del tema, rellenada, es UTF-8")
}
fn embedded_servida(code: u16) -> Vec<u8> {
let rellena = crate::render::plantilla(EMBEDDED_DEFAULT_PAGE.as_bytes(), code);
let texto = String::from_utf8(rellena.clone())
.expect("la página embebida del tema, rellenada, es UTF-8");
assert!(
texto.contains(&code.to_string()),
"red de la sustitución: la página embebida servida tiene que \
**nombrar** su código (`{code}`), fue: {texto}"
);
assert!(
!texto.contains("{{"),
"red de la sustitución: no puede quedar ningún marcador sin rellenar, fue: {texto}"
);
rellena
}
fn sin_contenido_dir() -> PathBuf {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/inexistente-y-ademas-inexistente");
assert!(
!dir.exists(),
"el fixture `sin contenido` no puede existir: {}",
dir.display()
);
dir
}
fn sin_contenido_config() -> Config {
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
sin_contenido_dir().to_string_lossy().into_owned(),
)])
.expect("la configuración de fixtures debe construirse")
}
fn default_418_sin_html_config() -> Config {
let mut config = no_html_config();
config.default_error_page = 418;
config
}
fn config_con(contenido: &Path, extras: &[(&str, &str)]) -> Config {
let mut pares: Vec<(String, String)> = vec![(
"CONTENT_DIR".to_string(),
contenido.to_string_lossy().into_owned(),
)];
pares.extend(
extras
.iter()
.map(|(clave, valor)| ((*clave).to_string(), (*valor).to_string())),
);
Config::from_env_iter(pares).expect("la configuración de pruebas debe construirse")
}
fn config_con_theme(theme: &str) -> Config {
config_con(&sin_contenido_dir(), &[("THEME", theme)])
}
fn get(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
async fn send(request: Request<Body>) -> axum::response::Response {
build_router(&fixtures_config())
.oneshot(request)
.await
.unwrap()
}
fn content_type(response: &axum::response::Response) -> String {
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string()
}
fn header_value(response: &axum::response::Response, name: &str) -> Option<String> {
response
.headers()
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
async fn body_bytes(response: axum::response::Response) -> Vec<u8> {
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec()
}
fn get_with_headers(uri: &str, headers: &[(&str, &str)]) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder.body(Body::empty()).unwrap()
}
fn gunzip(comprimido: &[u8]) -> Vec<u8> {
let mut descomprimido = Vec::new();
GzDecoder::new(comprimido)
.read_to_end(&mut descomprimido)
.expect("el cuerpo debe ser gzip válido");
descomprimido
}
#[tokio::test]
async fn una_pagina_404_responde_404_y_no_200() {
let response = send(get("/404")).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_ne!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn x_code_determina_el_estado_en_la_raiz() {
let request = Request::builder()
.uri("/")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_ne!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn la_raiz_sin_pistas_devuelve_el_codigo_por_defecto() {
let response = send(get("/")).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn el_estado_no_depende_del_formato_pedido() {
for uri in ["/503", "/503.json", "/503.xml", "/503.txt"] {
let response = send(get(uri)).await;
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"estado incorrecto en {uri}"
);
}
}
#[tokio::test]
async fn los_endpoints_de_salud_responden_200() {
for uri in ["/healthz", "/health", "/health/live", "/live"] {
let response = send(get(uri)).await;
assert_eq!(response.status(), StatusCode::OK, "fallo en {uri}");
assert!(
body_bytes(response).await.is_empty(),
"`{uri}` no debe devolver una página de error"
);
}
}
#[tokio::test]
async fn version_responde_json_con_la_clave_version() {
let response = send(get("/version")).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("application/json"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("version").is_some_and(|value| value.is_string()),
"el JSON debe llevar la clave `version`: {json}"
);
}
#[tokio::test]
async fn un_metodo_no_admitido_sobre_un_endpoint_de_servicio_conserva_el_405_de_axum() {
for uri in [
"/healthz",
"/health",
"/health/live",
"/live",
"/version",
"/metrics",
] {
for method in ["POST", "PUT"] {
let request = Request::builder()
.method(method)
.uri(uri)
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"`{method} {uri}` debe conservar el 405 de axum, no {:?}",
response.status()
);
assert_eq!(
header_value(&response, "Allow").as_deref(),
Some("GET,HEAD"),
"`{method} {uri}`: el 405 del endpoint describe la ruta con su `Allow`"
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"`{method} {uri}`: un endpoint de servicio no lleva las cabeceras \
de la página de error"
);
let body = body_bytes(response).await;
assert!(
body.is_empty(),
"`{method} {uri}`: la respuesta no puede ser una página de error, \
y este cuerpo no está vacío: {}",
String::from_utf8_lossy(&body)
);
}
}
}
#[tokio::test]
async fn los_css_se_sirven_desde_content_dir() {
let response = send(get("/css/404.css")).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("text/css"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert!(!body.is_empty(), "el CSS servido no puede estar vacío");
}
#[tokio::test]
async fn un_asset_con_if_modified_since_en_el_futuro_responde_304_y_no_una_pagina() {
let response = send(get_with_headers(
"/css/404.css",
&[("If-Modified-Since", "Tue, 01 Jan 2999 00:00:00 GMT")],
))
.await;
assert_eq!(
response.status(),
StatusCode::NOT_MODIFIED,
"`If-Modified-Since` posterior al `Last-Modified` del asset da `304`"
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"un `304` no es una página de error: no puede llevar sus cabeceras"
);
assert!(
body_bytes(response).await.is_empty(),
"un `304` no admite cuerpo"
);
}
#[tokio::test]
async fn un_componente_curdir_en_el_path_no_sirve_la_pagina_como_asset() {
let response = send(get("/./404.html")).await;
assert_ne!(
response.status(),
StatusCode::OK,
"`/./404.html` no puede servirse como asset con 200"
);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(body.trim(), fixture_servida(404).trim());
}
#[tokio::test]
async fn un_path_percent_codificado_no_sirve_la_pagina_como_asset() {
for uri in ["/404%2Ehtml", "/40%34.html", "/404%2eHTML", "/%2E/404.html"] {
let response = send(get(uri)).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"`{uri}` debe responder 404, no {:?}",
response.status()
);
}
}
#[tokio::test]
async fn el_html_de_una_pagina_de_error_no_se_sirve_nunca_con_200() {
let response = send(get("/404.html")).await;
assert_ne!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(body.trim(), fixture_servida(404).trim());
}
#[tokio::test]
async fn un_doble_percent_encoding_tampoco_esquiva_la_pagina_de_error() {
let response = send(get("/404%252Ehtml")).await;
assert_ne!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn no_se_puede_salir_del_directorio_de_contenido() {
for uri in [
"/../Cargo.toml",
"/..%2f..%2fetc%2fpasswd",
"/css/../../Cargo.toml",
] {
let response = send(get(uri)).await;
let status = response.status();
assert!(
(400..=599).contains(&status.as_u16()),
"`{uri}` debe estar en 400..=599, fue {status}"
);
let body = String::from_utf8_lossy(&body_bytes(response).await).to_string();
assert!(
!body.contains("[package]") && !body.contains("root:"),
"`{uri}` no puede filtrar el contenido de un fichero: {body}"
);
}
}
#[tokio::test]
async fn un_codigo_no_utilizable_en_el_path_responde_el_defecto() {
for uri in ["/200", "/100", "/204", "/304", "/1000", "/200.html"] {
let response = send(get(uri)).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"`{uri}` debe responder el código por defecto (404), no {:?}",
response.status()
);
}
}
#[tokio::test]
async fn un_codigo_no_utilizable_deja_paso_a_la_cabecera() {
let request = Request::builder()
.uri("/200")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
assert_eq!(
send(request).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[tokio::test]
async fn un_x_code_no_utilizable_cae_al_defecto() {
for value in ["200", "100", "204", "0", "1000", "abc", ""] {
let request = Request::builder()
.uri("/")
.header("X-Code", value)
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"`X-Code: {value}` debe caer al defecto (404), no {:?}",
response.status()
);
}
}
#[tokio::test]
async fn el_html_cae_al_defecto_conservando_el_codigo() {
let response = send(get("/418.html")).await;
assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(body.trim(), fixture_servida(418).trim());
}
#[tokio::test]
async fn el_404_lleva_x_robots_tag_y_no_lleva_retry_after() {
let response = send(get("/404")).await;
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive")
);
assert_eq!(header_value(&response, "Retry-After"), None);
}
#[tokio::test]
async fn el_503_lleva_retry_after_120() {
let response = send(get("/503")).await;
assert_eq!(
header_value(&response, "Retry-After").as_deref(),
Some("120")
);
}
#[tokio::test]
async fn el_json_se_anuncia_como_json() {
let response = send(get("/404.json")).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
content_type(&response).starts_with("application/json"),
"Content-Type inesperado: {}",
content_type(&response)
);
}
#[tokio::test]
async fn sin_accept_el_formato_por_defecto_es_texto_plano() {
let response = send(get("/404")).await;
assert!(
content_type(&response).starts_with("text/plain"),
"Content-Type inesperado: {}",
content_type(&response)
);
}
#[tokio::test]
async fn sin_fichero_html_el_estado_sigue_siendo_el_codigo_resuelto() {
let response = build_router(&default_418_sin_html_config())
.oneshot(get("/418.html"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(
content_type(&response).starts_with("text/html"),
"sin fichero en ninguna capa el cuerpo es la plantilla embebida (HTML), fue: {}",
content_type(&response)
);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive")
);
let body = body_bytes(response).await;
assert_eq!(
body,
embedded_servida(418),
"el cuerpo tiene que ser la plantilla embebida rellenada con `418`"
);
}
#[tokio::test]
async fn un_asset_inexistente_es_una_pagina_de_error() {
let response = send(get("/nada.css")).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive")
);
}
#[tokio::test]
async fn un_asset_existente_gana_a_la_cabecera_x_code() {
let request = Request::builder()
.uri("/css/404.css")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("text/css"),
"Content-Type inesperado: {}",
content_type(&response)
);
}
#[tokio::test]
async fn con_accept_encoding_gzip_la_respuesta_va_comprimida_y_conserva_el_codigo() {
let response = send(get_with_headers("/404", &[("Accept-Encoding", "gzip")])).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"el estado debe seguir siendo el código resuelto"
);
assert_eq!(
header_value(&response, "Content-Encoding").as_deref(),
Some("gzip"),
"con `Accept-Encoding: gzip` la respuesta debe ir comprimida"
);
let comprimido = body_bytes(response).await;
assert!(
!comprimido.is_empty(),
"el cuerpo comprimido no puede estar vacío"
);
let descomprimido = gunzip(&comprimido);
let en_claro = body_bytes(send(get("/404")).await).await;
assert_eq!(
descomprimido, en_claro,
"el cuerpo descomprimido debe ser idéntico al de la respuesta sin comprimir"
);
}
#[tokio::test]
async fn sin_accept_encoding_la_respuesta_no_va_comprimida() {
let response = send(get("/404")).await;
assert_eq!(
header_value(&response, "Content-Encoding"),
None,
"sin `Accept-Encoding` la respuesta no puede ir comprimida"
);
let body = body_bytes(response).await;
assert_eq!(
body,
render_text_fallback(404, 120).body,
"sin `Accept-Encoding` el cuerpo debe ser el de siempre"
);
}
#[tokio::test]
async fn toda_respuesta_comprimible_declara_vary_accept_encoding() {
for peticion in [
get("/404"),
get_with_headers("/404", &[("Accept-Encoding", "gzip")]),
] {
let response = send(peticion).await;
let vary = header_value(&response, "Vary").unwrap_or_default();
assert!(
vary.to_ascii_lowercase().contains("accept-encoding"),
"falta `Vary: Accept-Encoding` (Vary = {vary:?})"
);
}
}
const CABECERAS_DE_CORRELACION: [&str; 4] = [
"X-Request-Id",
"X-Trace-Id",
"X-Correlation-Id",
"X-Amzn-Trace-Id",
];
#[tokio::test]
async fn el_identificador_de_peticion_se_copia_a_la_respuesta() {
let response = send(get_with_headers("/404", &[("X-Request-Id", "abc123")])).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
header_value(&response, "X-Request-Id").as_deref(),
Some("abc123"),
"la respuesta debe repetir el `X-Request-Id` de la petición"
);
}
#[tokio::test]
async fn las_cuatro_cabeceras_de_correlacion_se_copian_juntas() {
let response = send(get_with_headers(
"/503",
&[
("X-Request-Id", "req-1"),
("X-Trace-Id", "trace-2"),
("X-Correlation-Id", "corr-3"),
("X-Amzn-Trace-Id", "Root=1-abc"),
],
))
.await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let esperadas =
CABECERAS_DE_CORRELACION
.iter()
.zip(["req-1", "trace-2", "corr-3", "Root=1-abc"]);
for (nombre, valor) in esperadas {
assert_eq!(
header_value(&response, nombre).as_deref(),
Some(valor),
"`{nombre}` no se copió a la respuesta"
);
}
}
#[tokio::test]
async fn una_cabecera_de_correlacion_ausente_no_se_inventa() {
let response = send(get("/404")).await;
for nombre in CABECERAS_DE_CORRELACION {
assert_eq!(
header_value(&response, nombre),
None,
"la respuesta no puede inventarse `{nombre}`"
);
}
}
#[tokio::test]
async fn un_asset_existente_tambien_propaga_las_cabeceras_de_correlacion() {
let response = send(get_with_headers(
"/css/404.css",
&[("X-Request-Id", "asset-7")],
))
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header_value(&response, "X-Request-Id").as_deref(),
Some("asset-7"),
"un asset también debe propagar las cabeceras de correlación"
);
}
#[tokio::test]
async fn un_endpoint_de_servicio_tambien_propaga_las_cabeceras() {
let response = send(get_with_headers(
"/version",
&[("X-Correlation-Id", "svc-9")],
))
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header_value(&response, "X-Correlation-Id").as_deref(),
Some("svc-9"),
"un endpoint de servicio también debe propagar las cabeceras"
);
}
fn contador_prometheus(cuerpo: &str, code: u16) -> Option<u64> {
let prefijo = format!("{COUNTER_NAME}{{code=\"{code}\",");
let mut total = None;
for linea in cuerpo.lines() {
let Some(resto) = linea.strip_prefix(&prefijo) else {
continue;
};
let Some((_, valor)) = resto.split_once("} ") else {
continue;
};
let Ok(valor) = valor.trim().parse::<u64>() else {
continue;
};
total = Some(total.unwrap_or(0) + valor);
}
total
}
async fn metrics_tras(peticiones: &[(u16, usize)]) -> axum::response::Response {
let router = build_router(&fixtures_config());
for (code, veces) in peticiones {
for _ in 0..*veces {
let uri = format!("/{code}");
let response = router.clone().oneshot(get(&uri)).await.unwrap();
assert_eq!(
response.status().as_u16(),
*code,
"la petición de preparación `{uri}` no devolvió {code}"
);
}
}
router.oneshot(get("/metrics")).await.unwrap()
}
#[tokio::test]
async fn metrics_es_un_endpoint_de_servicio_y_no_una_pagina_de_error() {
let response = build_router(&fixtures_config())
.oneshot(get("/metrics"))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"`/metrics` debe responder 200"
);
assert!(
content_type(&response).starts_with("text/plain"),
"Content-Type inesperado: {}",
content_type(&response)
);
assert!(
content_type(&response).contains("version=0.0.4"),
"el `Content-Type` debe ser el de Prometheus, fue: {}",
content_type(&response)
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"`/metrics` no puede pasar por la resolución de páginas de error"
);
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains("errors_pages_total"),
"`/metrics` debe declarar el contador:\n{cuerpo}"
);
}
#[tokio::test]
async fn el_contador_refleja_las_paginas_servidas_por_codigo() {
let response = metrics_tras(&[(404, 3), (503, 1)]).await;
assert_eq!(response.status(), StatusCode::OK);
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
contador_prometheus(&cuerpo, 404),
Some(3),
"tras 3 peticiones a `/404` el contador debe declarar 3:\n{cuerpo}"
);
assert_eq!(
contador_prometheus(&cuerpo, 503),
Some(1),
"tras 1 petición a `/503` el contador debe declarar 1:\n{cuerpo}"
);
}
#[tokio::test]
async fn metrics_declara_la_ayuda_y_el_tipo_del_contador() {
let response = metrics_tras(&[(404, 1)]).await;
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains("# HELP errors_pages_total Páginas de error servidas, por código."),
"falta la ayuda (`# HELP`) del contador:\n{cuerpo}"
);
assert!(
cuerpo.contains("# TYPE errors_pages_total counter"),
"falta el tipo (`# TYPE ... counter`) del contador:\n{cuerpo}"
);
}
#[tokio::test]
async fn el_path_gana_a_la_cabecera_y_el_contador_lo_declara_con_origen_path() {
let router = build_router(&fixtures_config());
let request = Request::builder()
.uri("/404")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router.oneshot(get("/metrics")).await.unwrap();
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains(r#"errors_pages_total{code="404",source="path"} 1"#),
"el path ganó: la página se cuenta con origen `path`:\n{cuerpo}"
);
assert!(
!cuerpo.contains(r#"source="header""#),
"el path ganó, así que ninguna serie puede declarar origen `header`:\n{cuerpo}"
);
}
fn uri_imposible() -> String {
format!("/{}", "a".repeat(300))
}
fn vary_names(response: &axum::response::Response) -> Vec<String> {
let mut names: Vec<String> = response
.headers()
.get_all(header::VARY)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| !name.is_empty())
.collect();
names.sort();
names.dedup();
names
}
#[tokio::test]
async fn un_segmento_de_path_imposible_da_la_pagina_de_error_y_no_un_500() {
let response = send(get(&uri_imposible())).await;
let status = response.status();
assert_ne!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"una URL larga no puede fabricar un 5xx del servicio"
);
assert!(
(400..=599).contains(&status.as_u16()),
"el estado debe estar en 400..=599, fue {status}"
);
assert_eq!(
status,
StatusCode::NOT_FOUND,
"el 500 del servidor de ficheros debe caer al código resuelto, no {status}"
);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive"),
"la página de error debe llevar sus cabeceras"
);
let body = body_bytes(response).await;
assert!(
!body.is_empty(),
"el `500` del servidor de ficheros sale con el cuerpo vacío; la página de error no"
);
assert_eq!(
body,
render_text_fallback(404, 120).body,
"el cuerpo debe ser el de la página del código resuelto (texto)"
);
}
#[tokio::test]
async fn un_metodo_no_admitido_sobre_un_asset_da_la_pagina_del_codigo_resuelto() {
let request = Request::builder()
.method("PUT")
.uri("/css/404.css")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_ne!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"el `405` del servidor de ficheros no puede ser el estado de la página"
);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive"),
"un `405` del servidor de ficheros se escapaba sin las cabeceras de la página"
);
let body = body_bytes(response).await;
assert!(
!body.is_empty(),
"el `405` del servidor de ficheros sale con el cuerpo vacío: debe ser la página"
);
assert_eq!(
body,
render_text_fallback(404, 120).body,
"el cuerpo debe ser el de la página del código resuelto (texto)"
);
}
#[tokio::test]
async fn un_asset_existente_sigue_sirviendose_con_200_y_no_como_pagina_de_error() {
let response = send(get("/css/404.css")).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("text/css"),
"Content-Type inesperado: {}",
content_type(&response)
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"un asset no es una página de error: no puede llevar sus cabeceras"
);
assert!(
!body_bytes(response).await.is_empty(),
"el CSS servido no puede estar vacío"
);
}
#[tokio::test]
async fn un_range_multiple_sobre_un_asset_da_la_pagina_del_codigo_resuelto() {
let response = send(get_with_headers(
"/css/404.css",
&[("Range", "bytes=0-0,1-1")],
))
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_ne!(
response.status(),
StatusCode::RANGE_NOT_SATISFIABLE,
"el `416` del servidor de ficheros no puede ser el estado de la página"
);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive"),
"el `416` del servidor de ficheros salía sin las cabeceras de la página"
);
assert!(
!body_bytes(response).await.is_empty(),
"el `416` del servidor de ficheros sale con el cuerpo vacío: debe ser la página"
);
}
#[tokio::test]
async fn un_asset_inexistente_con_x_code_responde_el_codigo_resuelto() {
let response = send(get_with_headers("/nada.png", &[("X-Code", "503")])).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
header_value(&response, "Retry-After").as_deref(),
Some("120"),
"el `503` servido tiene que llevar su `Retry-After`"
);
let body = body_bytes(response).await;
assert_eq!(
body,
render_text_fallback(503, 120).body,
"el cuerpo debe ser el de la página del código resuelto (`503`)"
);
}
#[tokio::test]
async fn el_retry_after_configurado_no_depende_del_origen_del_codigo() {
let router = build_router(&fixtures_config_con_retry_after("30"));
let casos = [
(
"el `503` del path, en JSON",
get("/503.json"),
StatusCode::SERVICE_UNAVAILABLE,
Some("application/json"),
),
(
"el `429` del path",
get("/429"),
StatusCode::TOO_MANY_REQUESTS,
None,
),
(
"el `503` de `X-Code` sobre un asset inexistente",
get_with_headers("/nada.png", &[("X-Code", "503")]),
StatusCode::SERVICE_UNAVAILABLE,
None,
),
];
for (etiqueta, request, estado, content_type_esperado) in casos {
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), estado, "estado de {etiqueta}");
assert_eq!(
header_value(&response, "Retry-After").as_deref(),
Some("30"),
"el valor configurado debe llegar a {etiqueta}, sin depender del \
formato ni del origen del código"
);
if let Some(content_type_esperado) = content_type_esperado {
assert_eq!(
header_value(&response, "Content-Type").as_deref(),
Some(content_type_esperado),
"Content-Type de {etiqueta}"
);
}
}
}
#[tokio::test]
async fn el_defecto_configurado_manda_sobre_el_not_found_del_servidor_de_ficheros() {
let response = build_router(&default_503_config())
.oneshot(get("/nada.png"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_ne!(response.status(), StatusCode::NOT_FOUND);
let body = body_bytes(response).await;
assert_eq!(
body,
render_text_fallback(503, 120).body,
"el cuerpo debe ser el de la página del defecto configurado (`503`)"
);
}
#[tokio::test]
async fn el_retry_after_corresponde_al_estado_servido() {
let interceptadas = [
get_with_headers("/nada.png", &[("X-Code", "503")]),
Request::builder()
.method("PUT")
.uri("/css/404.css")
.body(Body::empty())
.unwrap(),
get(&uri_imposible()),
];
for request in interceptadas {
let response = send(request).await;
let status = response.status();
let retry_after = header_value(&response, "Retry-After");
let debe_llevarlo = matches!(
status,
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
retry_after.is_some(),
debe_llevarlo,
"`Retry-After` = {retry_after:?} con estado {status}: solo `429` y `503` lo llevan"
);
}
}
#[tokio::test]
async fn una_respuesta_interceptada_sigue_la_cascada_de_formato() {
let request = Request::builder()
.method("PUT")
.uri("/css/404.css")
.header("Accept", "text/html")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert_eq!(
body,
fixture_servida(404).into_bytes(),
"el cuerpo debe ser la página HTML del código resuelto (`404`)"
);
}
#[tokio::test]
async fn vary_declara_la_compresion_y_las_cuatro_cabeceras_de_correlacion() {
for uri in ["/404", "/healthz", "/metrics"] {
let sin_correlacion = send(get(uri)).await;
let con_correlacion = send(get_with_headers(
uri,
&[
("X-Request-Id", "req-1"),
("X-Trace-Id", "trace-2"),
("X-Correlation-Id", "corr-3"),
("X-Amzn-Trace-Id", "Root=1-abc"),
],
))
.await;
let sin = vary_names(&sin_correlacion);
let con = vary_names(&con_correlacion);
assert_eq!(
con, sin,
"`{uri}`: `Vary` no puede depender de la petición: sin cabeceras {sin:?}, \
con ellas {con:?}"
);
for nombre in [
"accept-encoding",
"x-request-id",
"x-trace-id",
"x-correlation-id",
"x-amzn-trace-id",
] {
assert!(
sin.contains(&nombre.to_string()),
"`{uri}`: falta `{nombre}` en `Vary` (Vary = {sin:?})"
);
}
}
}
#[tokio::test]
async fn sin_directorio_una_pagina_html_sirve_la_copia_embebida_con_su_estado() {
let response = build_router(&sin_contenido_config())
.oneshot(get("/503.html"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert_eq!(
body,
embedded_servida(503),
"el cuerpo tiene que ser la plantilla embebida rellenada con `503`, autocontenida"
);
}
#[tokio::test]
async fn sin_directorio_los_once_codigos_sirven_su_pagina_embebida() {
let router = build_router(&sin_contenido_config());
for code in [400u16, 401, 403, 404, 405, 408, 429, 500, 502, 503, 504] {
let request = get_with_headers(&format!("/{code}"), &[("Accept", "text/html")]);
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(
response.status().as_u16(),
code,
"`/{code}` debe responder su propio estado"
);
assert!(
content_type(&response).starts_with("text/html"),
"`/{code}`: Content-Type inesperado: {}",
content_type(&response)
);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
body.contains(&format!("class=\"c{code}\"")),
"`/{code}`: el cuerpo tiene que ser la página embebida de su código, fue: {body}"
);
assert_ne!(
body.as_bytes(),
render_text_fallback(code, 120).body.as_slice(),
"`/{code}`: el cuerpo no puede ser el respaldo de texto"
);
}
}
#[tokio::test]
async fn sin_directorio_la_pagina_embebida_no_se_sirve_como_asset_con_200() {
let response = build_router(&sin_contenido_config())
.oneshot(get("/404.html"))
.await
.unwrap();
assert_ne!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert_eq!(
body,
embedded_servida(404),
"el cuerpo tiene que ser la plantilla embebida rellenada con `404`, autocontenida, \
no un asset ni el respaldo"
);
}
#[tokio::test]
async fn el_directorio_gana_a_la_pagina_embebida_por_su_defecto() {
let response = build_router(&fixtures_config())
.oneshot(get("/503.html"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(content_type(&response).starts_with("text/html"));
let body = body_bytes(response).await;
assert_eq!(
body,
fixture_servida(503).into_bytes(),
"el cuerpo tiene que ser el `404.html` del operador, rellenado con `503`"
);
assert_ne!(
body,
embedded_servida(503),
"la plantilla embebida no puede ganar al directorio del operador"
);
}
#[tokio::test]
async fn el_directorio_gana_a_la_pagina_embebida_del_mismo_codigo() {
let response = build_router(&fixtures_config())
.oneshot(get("/404.html"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = body_bytes(response).await;
assert_eq!(body, fixture_servida(404).into_bytes());
assert_ne!(
body,
embedded_servida(404),
"la copia embebida del `404` no puede ganar al `404.html` del operador"
);
}
#[tokio::test]
async fn un_path_inexistente_en_el_disco_sigue_dando_404_y_pagina_de_error() {
let response = build_router(&sin_contenido_config())
.oneshot(get("/no-existe-en-el-disco.css"))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"un path fuera del disco tiene que seguir respondiendo 404"
);
assert_ne!(response.status(), StatusCode::OK);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive"),
"el 404 del servidor de ficheros se sustituye por la página de error"
);
assert_eq!(
body_bytes(response).await,
render_text_fallback(404, 120).body,
"el cuerpo tiene que ser el de la página del código resuelto"
);
}
#[tokio::test]
async fn sin_pagina_en_ninguna_capa_un_codigo_distinto_del_defecto_no_da_500() {
let response = build_router(&default_418_sin_html_config())
.oneshot(get("/599.html"))
.await
.unwrap();
assert_eq!(response.status().as_u16(), 599);
assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(
content_type(&response).starts_with("text/html"),
"sin página en ninguna capa el cuerpo es la plantilla embebida (HTML): {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert_eq!(
body,
embedded_servida(599),
"el cuerpo tiene que ser la plantilla embebida rellenada con `599`"
);
}
#[tokio::test]
async fn el_html_servido_lleva_el_estilo_dentro_y_ningun_link() {
let router = build_router(&sin_contenido_config());
for code in [404u16, 503u16] {
let request = get_with_headers(&format!("/{code}"), &[("Accept", "text/html")]);
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status().as_u16(), code);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
!body.contains("<link"),
"`/{code}`: el cuerpo servido no puede llevar ningún `<link>`, fue: {body}"
);
assert!(
body.contains("<style"),
"`/{code}`: el cuerpo servido tiene que llevar su CSS en un bloque `<style>`, \
fue: {body}"
);
}
}
#[tokio::test]
async fn sin_theme_la_pagina_es_la_del_tema_default() {
let response = build_router(&sin_contenido_config())
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
pagina_del_tema("default", 404),
"sin `THEME` la página tiene que ser la del tema `default`"
);
}
#[tokio::test]
async fn theme_cambia_la_pagina_de_todo_el_servicio() {
let response = build_router(&config_con_theme("terminal"))
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
pagina_del_tema("terminal", 404),
"con `THEME=terminal` el cuerpo tiene que ser la página del tema `terminal`"
);
assert_ne!(
body,
pagina_del_tema("default", 404),
"con `THEME=terminal` el cuerpo NO puede ser la página del tema `default`"
);
}
#[tokio::test]
async fn la_pagina_del_tema_del_operador_gana_a_la_embebida() {
let response = build_router(&fixtures_config())
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
fixture_servida(404),
"la página `default.html` del operador tiene que ganar a la embebida"
);
assert_ne!(
body,
pagina_del_tema("default", 404),
"con su propia página, el tema no puede incrustar la copia embebida"
);
}
#[tokio::test]
async fn x_theme_elige_la_pagina_de_esa_peticion() {
let response = build_router(&config_con_theme("default"))
.oneshot(get_with_headers(
"/404",
&[("Accept", "text/html"), ("X-Theme", "terminal")],
))
.await
.unwrap();
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
pagina_del_tema("terminal", 404),
"`X-Theme: terminal` tiene que elegir la página del tema `terminal`"
);
}
#[tokio::test]
async fn un_x_theme_desconocido_cae_al_tema_configurado() {
let response = build_router(&config_con_theme("minimal"))
.oneshot(get_with_headers(
"/404",
&[("Accept", "text/html"), ("X-Theme", "neon")],
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
pagina_del_tema("minimal", 404),
"un `X-Theme` desconocido tiene que caer al tema configurado (`minimal`)"
);
assert_ne!(
body,
pagina_del_tema("terminal", 404),
"un `X-Theme` desconocido no puede elegir la página de otro tema"
);
}
#[tokio::test]
async fn x_theme_no_afecta_al_json() {
let response = build_router(&sin_contenido_config())
.oneshot(get_with_headers(
"/404",
&[("Accept", "application/json"), ("X-Theme", "terminal")],
))
.await
.unwrap();
assert!(
content_type(&response).starts_with("application/json"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json.get("code").and_then(|value| value.as_u64()),
Some(404),
"el JSON no depende del tema: {json}"
);
}
#[tokio::test]
async fn x_theme_no_afecta_a_xml_ni_a_texto() {
let router = build_router(&sin_contenido_config());
for accept in ["application/xml", "text/plain"] {
let con = router
.clone()
.oneshot(get_with_headers(
"/404",
&[("Accept", accept), ("X-Theme", "terminal")],
))
.await
.unwrap();
let sin = router
.clone()
.oneshot(get_with_headers("/404", &[("Accept", accept)]))
.await
.unwrap();
assert_eq!(con.status(), sin.status(), "el estado no depende del tema");
assert!(
content_type(&con).starts_with(accept),
"Content-Type inesperado para `{accept}`: {}",
content_type(&con)
);
assert_eq!(
body_bytes(con).await,
body_bytes(sin).await,
"`X-Theme` no puede cambiar el cuerpo de `{accept}`"
);
}
}
#[tokio::test]
async fn vary_declara_x_theme() {
let router = build_router(&sin_contenido_config());
for peticion in [
get("/404"),
get_with_headers("/404", &[("X-Theme", "terminal")]),
] {
let response = router.clone().oneshot(peticion).await.unwrap();
let names = vary_names(&response);
assert!(
names.contains(&"x-theme".to_string()),
"`Vary` tiene que declarar `X-Theme` (Vary = {names:?})"
);
}
}
#[tokio::test]
async fn una_pagina_del_operador_con_un_link_se_sirve_tal_cual() {
let content_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/con-marcador");
let config = config_con(&content_dir, &[]);
let response = build_router(&config)
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
body.contains("href=\"/css/otra.css\""),
"el `<link>` de la página del operador tiene que seguir en el cuerpo, fue: {body}"
);
}
#[tokio::test]
async fn la_pagina_del_operador_sin_estilo_propio_se_sirve_sin_estilo() {
let response = build_router(&fixtures_config())
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
let body = body_bytes(response).await;
assert_eq!(body, fixture_servida(404).into_bytes());
assert!(
!String::from_utf8_lossy(&body).contains("<style"),
"una página del operador sin `<style>` no puede servirse con uno: {}",
String::from_utf8_lossy(&body)
);
}
#[tokio::test]
async fn una_pagina_sin_marcadores_se_sirve_byte_a_byte_por_el_router() {
let content_dir =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pagina-sin-marcadores");
let config = Config::from_env_iter([(
"CONTENT_DIR".to_string(),
content_dir.to_string_lossy().into_owned(),
)])
.expect("la configuración del fixture `pagina-sin-marcadores` debe construirse");
let response = build_router(&config)
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let esperado = std::fs::read(content_dir.join("default.html"))
.expect("el fixture `pagina-sin-marcadores/default.html` tiene que existir");
let body = body_bytes(response).await;
assert_eq!(
body, esperado,
"una página sin marcadores se sirve **byte a byte**: el cuerpo tiene que ser el \
fichero tal cual, sin rellenar ni incrustar nada"
);
}
#[tokio::test]
async fn un_codigo_sin_fichero_propio_recibe_su_propio_cuerpo() {
let response = send(get_with_headers("/451", &[("Accept", "text/html")])).await;
assert_eq!(response.status().as_u16(), 451, "`/451` debe responder 451");
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains("451"),
"el cuerpo tiene que ser el de su propio código (`451`): {cuerpo}"
);
assert!(
!cuerpo.contains("404"),
"el cuerpo no puede nombrar el `404`: es el defecto que este change corrige: {cuerpo}"
);
}
#[tokio::test]
async fn la_clase_del_cuerpo_es_la_del_codigo_pedido() {
let response = send(get_with_headers("/451", &[("Accept", "text/html")])).await;
assert_eq!(response.status().as_u16(), 451);
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains(r#"class="c451""#),
"el cuerpo tiene que llevar la clase de su propio código (`c451`): {cuerpo}"
);
}
#[tokio::test]
async fn la_razon_y_la_descripcion_del_html_son_las_de_los_demas_formatos() {
let json = send(get("/400.json")).await;
let json: serde_json::Value = serde_json::from_slice(&body_bytes(json).await).unwrap();
let message = json["message"].as_str().unwrap().to_string();
let description = json["description"].as_str().unwrap().to_string();
let response = send(get_with_headers("/400", &[("Accept", "text/html")])).await;
assert_eq!(response.status().as_u16(), 400);
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains(&message),
"la razón del HTML tiene que ser el `message` del JSON (`{message}`): {cuerpo}"
);
assert!(
cuerpo.contains(&description),
"la descripción del HTML tiene que ser la `description` del JSON (`{description}`): \
{cuerpo}"
);
}
#[tokio::test]
async fn una_pagina_de_tema_ilegible_responde_el_respaldo_de_texto() {
let content_dir =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pagina-ilegible");
let config = config_con(&content_dir, &[]);
let response = build_router(&config)
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_ne!(
response.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"una página ilegible no puede fabricar un `500`: el estado es el código resuelto"
);
assert!(
content_type(&response).starts_with("text/plain"),
"sin poder leer la página, el cuerpo tiene que ser el respaldo de texto, fue: {}",
content_type(&response)
);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
body.contains("404"),
"el respaldo de texto debe llevar el código resuelto: {body}"
);
}
#[tokio::test]
async fn default_html_es_el_codigo_por_defecto_y_no_un_asset() {
let response = build_router(&fixtures_config())
.oneshot(get_with_headers(
"/default.html",
&[("Accept", "text/html")],
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_ne!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert_eq!(
body,
fixture_servida(404).into_bytes(),
"`/default.html` se renderiza con la página del tema `default`, rellenada con `404`"
);
}
#[tokio::test]
async fn default_htm_tambien_es_el_codigo_por_defecto() {
let response = build_router(&default_503_config())
.oneshot(get("/default.htm"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_ne!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn default_html_gana_a_x_code_y_el_origen_es_path() {
let router = build_router(&fixtures_config());
let request = Request::builder()
.uri("/default.html")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"el alias del path tiene que ganar a `X-Code`"
);
let response = router.oneshot(get("/metrics")).await.unwrap();
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert!(
cuerpo.contains(r#"errors_pages_total{code="404",source="path"} 1"#),
"el alias se cuenta con origen `path`:\n{cuerpo}"
);
assert!(
!cuerpo.contains(r#"source="header""#),
"el alias ganó, así que ninguna serie puede declarar origen `header`:\n{cuerpo}"
);
}
#[tokio::test]
async fn default_html_con_theme_matrix_sirve_la_pagina_de_matrix() {
let response = build_router(&config_con_theme("matrix"))
.oneshot(get_with_headers(
"/default.html",
&[("Accept", "text/html")],
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
body,
pagina_del_tema("matrix", 404),
"`/default.html` con `THEME=matrix` tiene que servir la página de `matrix`"
);
}
#[tokio::test]
async fn el_alias_se_resuelve_tras_normalizar_el_path() {
for uri in ["/./default.html", "/default%2Ehtml"] {
let response = build_router(&fixtures_config())
.oneshot(get(uri))
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"`{uri}` tiene que resolver el código por defecto, no {:?}",
response.status()
);
assert_ne!(
response.status(),
StatusCode::OK,
"`{uri}` no puede servirse como asset"
);
}
}
#[tokio::test]
async fn las_seis_paginas_de_tema_se_sirven_crudas_por_su_path() {
let content_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("content");
let router = build_router(&config_con(&content_dir, &[]));
for nombre in ["minimal", "terminal", "paper", "amber", "compact", "matrix"] {
let uri = format!("/{nombre}.html");
let response = router.clone().oneshot(get(&uri)).await.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"`{uri}` tiene que servirse como asset crudo con 200, no {:?}",
response.status()
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"`{uri}` es un asset: no lleva las cabeceras de la página de error"
);
assert!(
!body_bytes(response).await.is_empty(),
"`{uri}` no puede servirse vacío"
);
}
}
#[tokio::test]
async fn una_pagina_no_utf8_se_sirve_con_sus_bytes_intactos() {
let content_dir =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pagina-no-utf8");
let response = build_router(&config_con(&content_dir, &[]))
.oneshot(get_with_headers("/404", &[("Accept", "text/html")]))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
content_type(&response).starts_with("text/html"),
"Content-Type inesperado: {}",
content_type(&response)
);
let body = body_bytes(response).await;
assert!(
body.contains(&0xE9),
"el byte `\\xE9` de la página tiene que sobrevivir intacto: {body:?}"
);
assert!(
body.contains(&0xF1),
"el byte `\\xF1` de la página tiene que sobrevivir intacto: {body:?}"
);
assert!(
!body.windows(3).any(|ventana| ventana == b"\xEF\xBF\xBD"),
"no puede aparecer el carácter de reemplazo U+FFFD: el cuerpo se compone por bytes: \
{body:?}"
);
assert!(
body.windows(3).any(|ventana| ventana == b"404"),
"el código se tiene que rellenar igual aunque la página no sea UTF-8: {body:?}"
);
assert!(
!body.windows(8).any(|ventana| ventana == b"{{code}}"),
"no puede quedar ningún marcador sin rellenar: {body:?}"
);
}
#[tokio::test]
async fn head_de_un_asset_existente_responde_200_sin_cuerpo() {
let request = Request::builder()
.method("HEAD")
.uri("/css/404.css")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(response.status(), StatusCode::OK);
assert!(
content_type(&response).starts_with("text/css"),
"Content-Type inesperado: {}",
content_type(&response)
);
assert_eq!(
header_value(&response, "X-Robots-Tag"),
None,
"un asset no es una página de error: no lleva sus cabeceras"
);
assert!(
body_bytes(response).await.is_empty(),
"una respuesta a `HEAD` no lleva cuerpo"
);
}
#[tokio::test]
async fn head_de_un_path_inexistente_da_la_pagina_de_error_sin_cuerpo() {
let request = Request::builder()
.method("HEAD")
.uri("/no-existe-en-el-disco.css")
.body(Body::empty())
.unwrap();
let response = send(request).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"el estado tiene que ser el código resuelto, no 200"
);
assert_ne!(response.status(), StatusCode::OK);
assert_eq!(
header_value(&response, "X-Robots-Tag").as_deref(),
Some("noindex, nofollow, nosnippet, noarchive"),
"el 404 se sustituye por la página de error, con sus cabeceras"
);
assert!(
body_bytes(response).await.is_empty(),
"una respuesta a `HEAD` no lleva cuerpo"
);
}
}