use std::path::PathBuf;
use std::sync::Arc;
use axum::Router;
use axum::http::StatusCode;
use crate::api::api_router;
use crate::api::jobs::AltaDeTareaDelCable;
use crate::api::repositories::AltaDeRepositorioDelCable;
use crate::api::targets::AltaDeTargetDelCable;
use crate::state::EstadoDeBase;
use crate::test_support::{
DirectorioDePrueba, base_con_clave, body_json, body_text, capturar_log, config_con_dsn, delete,
destino_local_de_prueba, estado_de_prueba, estado_desarrollo, get, post_json, put_json,
};
const CREDENCIAL_DE_ACCESO: &str = "AKIAIOSFODNN7EXAMPLE-secreto-de-acceso-1-19";
const PASSPHRASE_DEL_REPOSITORIO: &str = "passphrase-distinguible-1-19-000000000000000001";
const CONTRASENA_DEL_TARGET: &str = "contrasena-distinguible-1-19-000000000000000001";
const CREDENCIALES: [&str; 3] = [
CREDENCIAL_DE_ACCESO,
PASSPHRASE_DEL_REPOSITORIO,
CONTRASENA_DEL_TARGET,
];
const CONTROL_BUSCABLE: &str = "repo-1-20-buscable-en-el-fichero";
const ACCESO_1_20: &str = "AKIAIOSFODNN7EXAMPLE-1-20-en-claro-no";
const PASSPHRASE_1_20: &str = "passphrase-distinguible-1-20-000000000000000020";
const CONTRASENA_1_20: &str = "contrasena-distinguible-1-20-000000000000000020";
const NOMBRE_DEL_RECHAZO: &str = "repo-1-22-rechazado";
const CREDENCIALES_1_20: [&str; 3] = [ACCESO_1_20, PASSPHRASE_1_20, CONTRASENA_1_20];
fn contiene(pajar: &[u8], aguja: &str) -> bool {
!aguja.is_empty()
&& pajar
.windows(aguja.len())
.any(|ventana| ventana == aguja.as_bytes())
}
fn bytes_de_los_ficheros(directorio: &DirectorioDePrueba) -> Vec<u8> {
let mut bytes = Vec::new();
for sufijo in ["", crate::db::SUFIJO_WAL, "-shm"] {
let ruta = PathBuf::from(format!(
"{}{sufijo}",
directorio.ruta_de_la_base().display()
));
if let Ok(contenido) = std::fs::read(&ruta) {
bytes.extend_from_slice(&contenido);
}
}
bytes
}
fn sin_credenciales(etiqueta: &str, texto: &str, credenciales: &[&str]) {
for credencial in credenciales {
assert!(
!texto.contains(credencial),
"la respuesta de {etiqueta} publicó una credencial: {texto}"
);
}
}
fn id_de(etiqueta: &str, texto: &str) -> String {
let cuerpo: serde_json::Value = serde_json::from_str(texto)
.unwrap_or_else(|error| panic!("la respuesta de {etiqueta} debe traer JSON: {error}"));
match cuerpo["data"]["id"].as_str() {
Some(id) => id.to_owned(),
None => panic!("la respuesta de {etiqueta} debe traer el `id` en `data`: {texto}"),
}
}
async fn alta_de(
router: &Router,
recurso: &str,
cuerpo: &serde_json::Value,
etiqueta: &str,
) -> String {
let respuesta = post_json(router.clone(), recurso, cuerpo).await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta de {etiqueta} debe contestar 201"
);
let texto = body_text(respuesta).await;
sin_credenciales(&format!("el alta de {etiqueta}"), &texto, &CREDENCIALES);
id_de(&format!("el alta de {etiqueta}"), &texto)
}
async fn barrido(
router: &Router,
recurso: &str,
cuerpo: &serde_json::Value,
etiqueta: &str,
) -> String {
let id = alta_de(router, recurso, cuerpo, etiqueta).await;
let lectura = get(router.clone(), &format!("{recurso}/{id}")).await;
assert_eq!(
lectura.status(),
StatusCode::OK,
"la lectura de {etiqueta} debe contestar 200"
);
sin_credenciales(
&format!("la lectura de {etiqueta}"),
&body_text(lectura).await,
&CREDENCIALES,
);
let actualizacion = put_json(router.clone(), &format!("{recurso}/{id}"), cuerpo).await;
assert_eq!(
actualizacion.status(),
StatusCode::OK,
"la actualización de {etiqueta} debe contestar 200"
);
sin_credenciales(
&format!("la actualización de {etiqueta}"),
&body_text(actualizacion).await,
&CREDENCIALES,
);
let borrado = delete(router.clone(), &format!("{recurso}/{id}")).await;
assert_eq!(
borrado.status(),
StatusCode::OK,
"el borrado de {etiqueta} debe contestar 200"
);
sin_credenciales(
&format!("el borrado de {etiqueta}"),
&body_text(borrado).await,
&CREDENCIALES,
);
id
}
fn cuerpo_de_repositorio(nombre: &str, credencial: &str, passphrase: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"endpoint": destino_local_de_prueba(nombre),
"bucket": nombre,
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": credencial,
"passphrase": passphrase,
})
}
fn cuerpo_de_target(nombre: &str, contrasena: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"target_type": "mariadb",
"container_name": "mariadb-de-prueba",
"db_user": "kesto",
"db_password": contrasena,
"db_name": "kesto",
})
}
fn cuerpo_de_tarea(target_id: &str, repository_id: &str) -> serde_json::Value {
serde_json::json!({
"target_id": target_id,
"repository_id": repository_id,
"cron_expression": "0 3 * * *",
"keep_daily": 7,
"keep_weekly": 4,
"keep_monthly": 12,
"enabled": true,
})
}
#[tokio::test]
async fn ninguna_respuesta_de_los_tres_recursos_trae_un_secreto() {
let router = api_router(estado_desarrollo().await);
barrido(
&router,
"/api/v1/repositories",
&cuerpo_de_repositorio(
"repo-1-19",
CREDENCIAL_DE_ACCESO,
PASSPHRASE_DEL_REPOSITORIO,
),
"un repositorio",
)
.await;
barrido(
&router,
"/api/v1/targets",
&cuerpo_de_target("target-1-19", CONTRASENA_DEL_TARGET),
"un target",
)
.await;
let repository_id = alta_de(
&router,
"/api/v1/repositories",
&cuerpo_de_repositorio(
"repo-1-19-sembrado",
CREDENCIAL_DE_ACCESO,
PASSPHRASE_DEL_REPOSITORIO,
),
"el repositorio sembrado",
)
.await;
let target_id = alta_de(
&router,
"/api/v1/targets",
&cuerpo_de_target("target-1-19-sembrado", CONTRASENA_DEL_TARGET),
"el target sembrado",
)
.await;
barrido(
&router,
"/api/v1/jobs",
&cuerpo_de_tarea(&target_id, &repository_id),
"una tarea",
)
.await;
}
#[tokio::test]
async fn la_credencial_que_entro_por_la_api_no_aparece_en_los_bytes_del_fichero() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
);
let router = api_router(estado);
alta_de(
&router,
"/api/v1/repositories",
&cuerpo_de_repositorio(CONTROL_BUSCABLE, ACCESO_1_20, PASSPHRASE_1_20),
"un repositorio",
)
.await;
alta_de(
&router,
"/api/v1/targets",
&cuerpo_de_target("target-1-20", CONTRASENA_1_20),
"un target",
)
.await;
let bytes = bytes_de_los_ficheros(&directorio);
assert!(
contiene(&bytes, CONTROL_BUSCABLE),
"el control no aparece en los bytes: la búsqueda no está mirando donde cree"
);
for credencial in CREDENCIALES_1_20 {
assert!(
!contiene(&bytes, credencial),
"la credencial {credencial} quedó en claro en los ficheros de la base"
);
}
}
#[tokio::test]
async fn el_registro_de_un_alta_no_publica_la_credencial_y_deja_la_traza_de_que_se_atendio() {
let router = api_router(estado_desarrollo().await);
let (buffer, _guard) = capturar_log();
let respuesta = post_json(
router,
"/api/v1/repositories",
&cuerpo_de_repositorio(
"repo-1-21",
CREDENCIAL_DE_ACCESO,
PASSPHRASE_DEL_REPOSITORIO,
),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta del escenario tiene que atenderse"
);
let log = buffer.texto();
for credencial in CREDENCIALES {
assert!(
!log.contains(credencial),
"el registro del alta publicó la credencial {credencial}: {log}"
);
}
assert!(
log.contains("petición atendida"),
"el registro no deja constancia de que la petición se atendió: {log}"
);
}
#[tokio::test]
async fn un_rechazo_de_validacion_deja_la_traza_del_campo_y_no_el_cuerpo() {
let router = api_router(estado_desarrollo().await);
let (buffer, _guard) = capturar_log();
let respuesta = post_json(
router,
"/api/v1/repositories",
&cuerpo_de_repositorio(NOMBRE_DEL_RECHAZO, CREDENCIAL_DE_ACCESO, ""),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::BAD_REQUEST,
"una passphrase vacía no puede darse de alta"
);
let devuelto = body_json(respuesta).await;
assert!(
devuelto["message"]
.as_str()
.unwrap_or_default()
.contains("passphrase"),
"el 400 debe nombrar el campo que no cumplía: {devuelto}"
);
let log = buffer.texto();
assert!(
log.contains("passphrase"),
"el registro no deja la traza del campo que no cumplía: {log}"
);
assert!(
!log.contains(CREDENCIAL_DE_ACCESO),
"el registro del rechazo publicó la credencial: {log}"
);
assert!(
!log.contains(NOMBRE_DEL_RECHAZO),
"el registro del rechazo publicó el cuerpo de la petición: {log}"
);
}
const CREDENCIAL_DE_ACCESO_DEL_CABLE: &str = "AKIAIOSFODNN7EXAMPLE-1-17-no-debe-salir";
const PASSPHRASE_DEL_CABLE: &str = "passphrase-1-17-no-debe-salir-0000000000001";
const CONTRASENA_DEL_CABLE: &str = "contrasena-1-17-no-debe-salir-00000000000001";
#[test]
fn el_debug_del_cuerpo_del_cable_no_publica_la_credencial() {
fn comprobar_redaccion(etiqueta: &str, salida: &str, credenciales: &[&str]) {
for (indice, credencial) in credenciales.iter().enumerate() {
assert!(
!salida.contains(*credencial),
"el `Debug` de `{etiqueta}` publicó la credencial en la posición {indice}: {salida}"
);
}
assert!(
salida.to_lowercase().contains("redact"),
"el `Debug` de `{etiqueta}` no dice que la credencial está redactada —y una salida que no lo dice se confunde con un campo vacío—: {salida}"
);
}
fn comprobar_sin_credenciales(etiqueta: &str, salida: &str, credenciales: &[&str]) {
for (indice, credencial) in credenciales.iter().enumerate() {
assert!(
!salida.contains(*credencial),
"el `Debug` de `{etiqueta}` publicó la credencial en la posición {indice}: {salida}"
);
}
}
let json_del_repositorio = format!(
r#"{{"name":"repo-1-17","endpoint":"https://s3.example.test/1-17","bucket":"cubo","region":"eu-west-1","access_key_id":"AKIAIOSFODNN7EXAMPLE","secret_access_key":"{CREDENCIAL_DE_ACCESO_DEL_CABLE}","passphrase":"{PASSPHRASE_DEL_CABLE}"}}"#
);
let cuerpo_del_repositorio: AltaDeRepositorioDelCable =
serde_json::from_str(&json_del_repositorio)
.expect("el cuerpo del alta de un repositorio tiene que deserializar");
comprobar_redaccion(
"AltaDeRepositorioDelCable",
&format!("{cuerpo_del_repositorio:?}"),
&[CREDENCIAL_DE_ACCESO_DEL_CABLE, PASSPHRASE_DEL_CABLE],
);
let json_del_target = format!(
r#"{{"name":"target-1-17","target_type":"mariadb","container_name":"contenedor","db_user":"usuario","db_password":"{CONTRASENA_DEL_CABLE}","db_name":"base"}}"#
);
let cuerpo_del_target: AltaDeTargetDelCable = serde_json::from_str(&json_del_target)
.expect("el cuerpo del alta de un target tiene que deserializar");
comprobar_redaccion(
"AltaDeTargetDelCable",
&format!("{cuerpo_del_target:?}"),
&[CONTRASENA_DEL_CABLE],
);
let json_de_la_tarea = r#"{"target_id":"00000000-0000-4000-8000-000000000000","repository_id":"11111111-1111-4111-8111-111111111111","cron_expression":"0 3 * * *","keep_daily":7,"keep_weekly":4,"keep_monthly":12,"enabled":true}"#;
let cuerpo_de_la_tarea: AltaDeTareaDelCable = serde_json::from_str(json_de_la_tarea)
.expect("el cuerpo del alta de una tarea tiene que deserializar");
comprobar_sin_credenciales(
"AltaDeTareaDelCable",
&format!("{cuerpo_de_la_tarea:?}"),
&[
CREDENCIAL_DE_ACCESO_DEL_CABLE,
PASSPHRASE_DEL_CABLE,
CONTRASENA_DEL_CABLE,
],
);
}