use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::api::contenido;
use crate::api::extract::Json;
use crate::api::response::{ApiResponse, Data};
use crate::db::entities::{AltaDeTarget, Target, TipoDeTarget};
use crate::error::AppError;
use crate::secret::SecretString;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/targets", get(listado).post(alta))
.route(
"/api/v1/targets/{id}",
get(leer).put(actualizar).delete(borrar),
)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AltaDeTargetDelCable {
pub name: String,
pub target_type: String,
pub container_name: Option<String>,
pub db_user: Option<String>,
pub db_password: Option<SecretString>,
pub db_name: Option<String>,
pub host_path: Option<String>,
pub db_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TargetDelCable {
pub id: String,
pub name: String,
pub target_type: String,
pub container_name: Option<String>,
pub db_user: Option<String>,
pub has_db_password: bool,
pub db_name: Option<String>,
pub host_path: Option<String>,
pub db_path: Option<String>,
pub created_at: String,
}
impl TargetDelCable {
fn desde(fila: &Target) -> Result<Self, AppError> {
Ok(Self {
id: fila.id.hyphenated().to_string(),
name: fila.name.clone(),
target_type: texto_del_tipo(fila.target_type).to_owned(),
container_name: fila.container_name.clone(),
db_user: fila.db_user.clone(),
has_db_password: fila
.db_password
.as_ref()
.is_some_and(|secreto| !secreto.expose().is_empty()),
db_name: fila.db_name.clone(),
host_path: fila.host_path.clone(),
db_path: fila.db_path.clone(),
created_at: fila.created_at.format(&Rfc3339).map_err(|error| {
AppError::Other(format!("la fecha de la fila no se pudo formatear: {error}"))
})?,
})
}
}
pub(crate) fn texto_del_tipo(tipo: TipoDeTarget) -> &'static str {
match tipo {
TipoDeTarget::Mariadb => "mariadb",
TipoDeTarget::Postgresql => "postgresql",
TipoDeTarget::Directory => "directory",
TipoDeTarget::Sqlite => "sqlite",
}
}
fn tipo_del_cable(texto: &str) -> Result<TipoDeTarget, AppError> {
match texto {
"mariadb" => Ok(TipoDeTarget::Mariadb),
"postgresql" => Ok(TipoDeTarget::Postgresql),
"directory" => Ok(TipoDeTarget::Directory),
"sqlite" => Ok(TipoDeTarget::Sqlite),
_ => Err(AppError::InvalidInput(
"el campo `target_type` no admite ese valor: se espera `directory`, `mariadb`, \
`postgresql` o `sqlite`"
.to_owned(),
)),
}
}
fn identificador(id: &str) -> Result<Uuid, AppError> {
Uuid::parse_str(id).map_err(|_| {
AppError::InvalidInput("el campo `id` de la ruta no tiene la forma de un UUID".to_owned())
})
}
fn target_ausente() -> AppError {
AppError::NotFound("no existe ningún target con ese identificador".to_owned())
}
fn falta(campo: &str) -> AppError {
AppError::InvalidInput(format!(
"el campo `{campo}` es obligatorio para este tipo de target"
))
}
fn vacio(campo: &str) -> AppError {
AppError::InvalidInput(format!("el campo `{campo}` no puede ir vacío"))
}
fn no_admitido(campo: &str) -> AppError {
AppError::InvalidInput(format!(
"el campo `{campo}` no se admite para este tipo de target"
))
}
fn exigir_presente(campo: &str, valor: Option<&str>) -> Result<String, AppError> {
match valor {
Some(texto) if !texto.is_empty() => Ok(texto.to_owned()),
Some(_) => Err(vacio(campo)),
None => Err(falta(campo)),
}
}
fn opcional_no_vacio(campo: &str, valor: Option<&str>) -> Result<Option<String>, AppError> {
match valor {
None => Ok(None),
Some("") => Err(vacio(campo)),
Some(texto) => Ok(Some(texto.to_owned())),
}
}
fn prohibir(campo: &str, valor: Option<&str>) -> Result<(), AppError> {
match valor {
None => Ok(()),
Some(_) => Err(no_admitido(campo)),
}
}
fn alta_desde(
cuerpo: &AltaDeTargetDelCable,
almacenada: Option<&SecretString>,
) -> Result<AltaDeTarget, AppError> {
let target_type = tipo_del_cable(&cuerpo.target_type)?;
match target_type {
TipoDeTarget::Directory => {
let host_path = exigir_presente("host_path", cuerpo.host_path.as_deref())?;
prohibir("container_name", cuerpo.container_name.as_deref())?;
prohibir("db_user", cuerpo.db_user.as_deref())?;
prohibir("db_name", cuerpo.db_name.as_deref())?;
prohibir("db_path", cuerpo.db_path.as_deref())?;
if cuerpo.db_password.is_some() {
return Err(no_admitido("db_password"));
}
Ok(AltaDeTarget {
name: cuerpo.name.clone(),
target_type,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: Some(host_path),
db_path: None,
})
}
TipoDeTarget::Mariadb | TipoDeTarget::Postgresql => {
let container_name =
exigir_presente("container_name", cuerpo.container_name.as_deref())?;
let db_user = exigir_presente("db_user", cuerpo.db_user.as_deref())?;
let db_password = match &cuerpo.db_password {
Some(secreto) if secreto.expose().is_empty() => return Err(vacio("db_password")),
Some(secreto) => secreto.clone(),
None => match almacenada {
Some(secreto) => secreto.clone(),
None => return Err(falta("db_password")),
},
};
let db_name = opcional_no_vacio("db_name", cuerpo.db_name.as_deref())?;
prohibir("host_path", cuerpo.host_path.as_deref())?;
prohibir("db_path", cuerpo.db_path.as_deref())?;
Ok(AltaDeTarget {
name: cuerpo.name.clone(),
target_type,
container_name: Some(container_name),
db_user: Some(db_user),
db_password: Some(db_password),
db_name,
host_path: None,
db_path: None,
})
}
TipoDeTarget::Sqlite => {
let container_name =
exigir_presente("container_name", cuerpo.container_name.as_deref())?;
let db_path = exigir_presente("db_path", cuerpo.db_path.as_deref())?;
prohibir("db_user", cuerpo.db_user.as_deref())?;
prohibir("db_name", cuerpo.db_name.as_deref())?;
prohibir("host_path", cuerpo.host_path.as_deref())?;
if cuerpo.db_password.is_some() {
return Err(no_admitido("db_password"));
}
Ok(AltaDeTarget {
name: cuerpo.name.clone(),
target_type,
container_name: Some(container_name),
db_user: None,
db_password: None,
db_name: None,
host_path: None,
db_path: Some(db_path),
})
}
}
}
pub async fn listado(State(state): State<AppState>) -> Result<ApiResponse, AppError> {
let filas = state.db.targets.listar().await?;
let vistas: Vec<TargetDelCable> = filas
.iter()
.map(TargetDelCable::desde)
.collect::<Result<_, _>>()?;
Ok(ApiResponse::new(
StatusCode::OK,
"targets listados",
contenido(&vistas)?,
))
}
pub async fn alta(
State(state): State<AppState>,
Json(cuerpo): Json<AltaDeTargetDelCable>,
) -> Result<ApiResponse, AppError> {
let alta = alta_desde(&cuerpo, None)?;
let fila = state.db.targets.alta(alta).await?;
let vista = TargetDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::CREATED,
"target creado",
contenido(&vista)?,
))
}
pub async fn leer(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let Some(fila) = state.db.targets.leer(&uuid).await? else {
return Err(target_ausente());
};
let vista = TargetDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"target encontrado",
contenido(&vista)?,
))
}
pub async fn actualizar(
State(state): State<AppState>,
Path(id): Path<String>,
Json(cuerpo): Json<AltaDeTargetDelCable>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let Some(actual) = state.db.targets.leer(&uuid).await? else {
return Err(target_ausente());
};
let alta = alta_desde(&cuerpo, actual.db_password.as_ref())?;
let fila = Target {
id: actual.id,
name: alta.name,
target_type: alta.target_type,
container_name: alta.container_name,
db_user: alta.db_user,
db_password: alta.db_password,
db_name: alta.db_name,
host_path: alta.host_path,
db_path: alta.db_path,
created_at: actual.created_at,
};
if !state.db.targets.actualizar(&fila).await? {
return Err(target_ausente());
}
let vista = TargetDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"target actualizado",
contenido(&vista)?,
))
}
pub async fn borrar(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
if !state.db.targets.borrar(&uuid).await? {
return Err(target_ausente());
}
Ok(ApiResponse::new(
StatusCode::OK,
"target borrado",
Data::None,
))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::http::StatusCode;
use crate::api::api_router;
use crate::state::EstadoDeBase;
use crate::test_support::{
base_con_clave, body_json, body_text, config_con_dsn, delete, estado_de_prueba,
estado_desarrollo, get, post_json, put_json,
};
const CREDENCIAL_DE_PRUEBA: &str = "AKIAIOSFODNN7EXAMPLE-credencial-del-target-0001";
const CONTRASENA_DE_TARGET: &str = "contrasena-distinguible-0000000000000001";
fn cuerpo_de_target(nombre: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"target_type": "mariadb",
"container_name": "contenedor-de-pruebas",
"db_user": "usuario-de-pruebas",
"db_password": CONTRASENA_DE_TARGET,
"db_name": "base-de-pruebas",
})
}
#[tokio::test]
async fn las_cinco_rutas_del_recurso_contestan_su_codigo() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&cuerpo_de_target("target-1-4"),
)
.await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado, y contestó {status}"
);
let cuerpo = body_json(respuesta).await;
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let respuesta = get(router.clone(), "/api/v1/targets").await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"el listado tiene que contestar 200 con un array en `data`, y contestó {status}"
);
let _ = body_json(respuesta).await;
let respuesta = get(router.clone(), &format!("/api/v1/targets/{id}")).await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"la lectura de una fila recién creada tiene que contestar 200, y contestó {status}"
);
let _ = body_json(respuesta).await;
let respuesta = put_json(
router.clone(),
&format!("/api/v1/targets/{id}"),
&cuerpo_de_target("target-1-4-modificado"),
)
.await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"la actualización tiene que contestar 200 con el recurso, y contestó {status}"
);
let _ = body_json(respuesta).await;
let respuesta = delete(router, &format!("/api/v1/targets/{id}")).await;
let status = respuesta.status();
assert_eq!(
status,
StatusCode::OK,
"el borrado tiene que contestar 200 con `data` nulo, y contestó {status}"
);
let _ = body_json(respuesta).await;
}
#[tokio::test]
async fn el_alta_de_un_target_no_trae_la_contrasena_y_si_el_booleano() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&cuerpo_de_target("target-1-16"),
)
.await;
let status = respuesta.status();
let crudo = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado, y contestó {status} con {crudo}"
);
let cuerpo: serde_json::Value = serde_json::from_str(&crudo)
.expect("el alta tiene que contestar un objeto JSON con la envoltura");
assert_eq!(
cuerpo["data"]["has_db_password"], true,
"el alta tiene que marcar que hay contraseña guardada: {cuerpo}"
);
assert!(
cuerpo["data"]["db_password"].is_null(),
"la contraseña no puede salir del servidor, ni siquiera con otro nombre: {cuerpo}"
);
assert!(
!crudo.contains(CONTRASENA_DE_TARGET),
"el cuerpo crudo de la respuesta no puede contener la contraseña que se mandó"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let respuesta = get(router, &format!("/api/v1/targets/{id}")).await;
let status = respuesta.status();
let crudo = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"la lectura del target recién creado tiene que contestar 200, y contestó {status} con \
{crudo}"
);
let cuerpo: serde_json::Value = serde_json::from_str(&crudo)
.expect("la lectura tiene que contestar un objeto JSON con la envoltura");
assert_eq!(
cuerpo["data"]["has_db_password"], true,
"la lectura tiene que marcar que hay contraseña guardada: {cuerpo}"
);
assert!(
cuerpo["data"]["db_password"].is_null(),
"la lectura tampoco puede devolver la contraseña: {cuerpo}"
);
assert!(
!crudo.contains(CONTRASENA_DE_TARGET),
"el cuerpo crudo de la lectura no puede contener la contraseña que se mandó"
);
assert_eq!(
cuerpo["data"]["target_type"], "mariadb",
"el tipo viaja como el texto que acepta el `CHECK` del §3: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["db_name"], "base-de-pruebas",
"la base concreta informada tiene que volver tal cual: {cuerpo}"
);
}
#[tokio::test]
async fn un_directory_con_container_name_se_rechaza_nombrando_el_campo() {
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 cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-26",
"target_type": "directory",
"host_path": "/srv/datos",
"container_name": "contenedor-que-no-toca",
});
let respuesta = post_json(
api_router(estado),
"/api/v1/targets",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `directory` que informa un contenedor es un formulario incoherente, no un servidor \
roto: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("container_name"),
"el rechazo tiene que nombrar el campo que lo provoca, y el mensaje fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn un_directory_sin_host_path_se_rechaza_nombrando_el_campo() {
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 cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-26b",
"target_type": "directory",
});
let respuesta = post_json(
api_router(estado),
"/api/v1/targets",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `directory` sin ruta de host es un formulario incompleto: contestó {status} con \
{cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("host_path"),
"el rechazo tiene que nombrar el campo que falta, y el mensaje fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn un_mariadb_sin_db_password_se_rechaza_nombrando_el_campo() {
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 cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-27",
"target_type": "mariadb",
"container_name": "contenedor",
"db_user": "usuario",
});
let respuesta = post_json(
api_router(estado),
"/api/v1/targets",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un target de base de datos sin contraseña no puede extraer nada: contestó {status} con \
{cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("db_password"),
"el rechazo tiene que nombrar el campo que falta, y el mensaje fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
#[tokio::test]
async fn un_db_name_ausente_queda_nulo_en_la_columna() {
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 cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-28",
"target_type": "mariadb",
"container_name": "contenedor",
"db_user": "usuario",
"db_password": "contrasena-distinguible-0000000000000002",
});
let respuesta = post_json(
api_router(estado),
"/api/v1/targets",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un target válido de base de datos sin `db_name` es un volcado global, que es válido: \
contestó {status} con {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let db_name: Option<String> =
sqlx::query_scalar("SELECT db_name FROM backup_targets WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila tiene que estar en el fichero con el que se abrió el estado");
assert!(
db_name.is_none(),
"`db_name` ausente tiene que quedar NULL de verdad —lo que significa volcado \
global— y no una cadena vacía, que sería una base llamada «»: quedó {db_name:?}"
);
}
#[tokio::test]
async fn un_campo_de_texto_vacio_se_rechaza_nombrando_el_campo() {
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);
let casos = [
(
serde_json::json!({
"name": "target-1-29",
"target_type": "mariadb",
"container_name": "",
"db_user": "usuario",
"db_password": "c",
}),
"container_name",
),
(
serde_json::json!({
"name": "target-1-29",
"target_type": "mariadb",
"container_name": "contenedor",
"db_user": "usuario",
"db_password": "c",
"db_name": "",
}),
"db_name",
),
];
for (cuerpo_de_la_peticion, campo) in casos {
let respuesta =
post_json(router.clone(), "/api/v1/targets", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `{campo}` informado y vacío es una cadena vacía, no un campo ausente: contestó \
{status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains(campo),
"el rechazo tiene que nombrar el campo que lo provoca —{campo}—, y el mensaje fue \
{mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect(
"la tabla tiene que ser contable sobre el fichero con el que se abrió el estado",
);
assert_eq!(
filas, 0,
"la petición rechazada por `{campo}` no puede dejar fila escrita: quedaron {filas}"
);
}
}
#[tokio::test]
async fn el_400_nombra_el_campo_y_no_filtra_el_valor() {
let cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-31",
"target_type": "directory",
"host_path": "/srv/datos",
"container_name": CREDENCIAL_DE_PRUEBA,
});
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(router, "/api/v1/targets", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let crudo = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `directory` que informa un contenedor se rechaza: contestó {status}"
);
let cuerpo: serde_json::Value = serde_json::from_str(&crudo)
.expect("la envoltura del rechazo tiene que ser un objeto JSON");
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("container_name"),
"el rechazo tiene que nombrar el campo que lo provoca, y el mensaje fue {mensaje:?}"
);
assert!(
!crudo.contains(CREDENCIAL_DE_PRUEBA),
"el rechazo no puede repetir el valor del campo: lo pondría en el cuerpo de la \
respuesta y de ahí a cualquier registro intermedio; el mensaje fue {mensaje:?}"
);
}
#[tokio::test]
async fn un_identificador_de_target_mal_formado_contesta_400() {
let router = api_router(estado_desarrollo().await);
let respuesta = get(router, "/api/v1/targets/no-es-un-uuid").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un identificador que no es un UUID no puede ser un recurso ausente ni un fallo del \
servidor: contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["status"], 400,
"el rechazo tiene que llegar en la envoltura del resto de la API, con el código \
dentro: {cuerpo}"
);
assert!(
cuerpo["message"].is_string(),
"la envoltura de error tiene que traer un `message` de texto: {cuerpo}"
);
}
#[tokio::test]
async fn un_put_sin_db_password_conserva_la_almacenada() {
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);
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&cuerpo_de_target("target-1-32t"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 antes de poder actualizar nada"
);
let cuerpo = body_json(respuesta).await;
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let sobre_antes: Option<String> =
sqlx::query_scalar("SELECT db_password FROM backup_targets WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila tiene que estar en el fichero con el que se abrió el estado");
let sobre_antes = sobre_antes.expect(
"el alta de un target de base de datos tiene que dejar la contraseña guardada en la \
columna, cifrada",
);
let contrasena_antes = clave
.descifrar(&sobre_antes)
.expect("el alta tiene que haber guardado la contraseña cifrada y descifrable")
.expose()
.to_owned();
let respuesta = put_json(
router.clone(),
&format!("/api/v1/targets/{id}"),
&serde_json::json!({
"name": "target-1-32t-editado",
"target_type": "mariadb",
"container_name": "contenedor-editado",
"db_user": "usuario-editado",
"db_name": "base-editada",
}),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"la actualización de una fila existente tiene que contestar 200; contestó {status} con \
{cuerpo}"
);
let sobre_despues: Option<String> =
sqlx::query_scalar("SELECT db_password FROM backup_targets WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila actualizada tiene que seguir en el fichero de la base");
let sobre_despues = sobre_despues.expect(
"un PUT que no informa la contraseña no puede vaciar la columna: la fila actualizada \
tiene que seguir teniendo su sobre",
);
let contrasena_despues = clave
.descifrar(&sobre_despues)
.expect("la actualización tiene que dejar la contraseña cifrada y descifrable")
.expose()
.to_owned();
assert_eq!(
contrasena_despues, contrasena_antes,
"un PUT que no informa `db_password` no puede borrarlo: el panel no tiene el valor —la \
API no lo devuelve— y sin esta regla reescribiría la credencial con nada"
);
let respuesta = get(router, &format!("/api/v1/targets/{id}")).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"la fila actualizada tiene que seguir leyéndose por la API; contestó {status} con \
{cuerpo}"
);
assert_eq!(
cuerpo["data"]["name"], "target-1-32t-editado",
"el PUT tiene que haber aplicado el nombre que sí informó: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["container_name"], "contenedor-editado",
"el PUT tiene que haber aplicado el contenedor que sí informó: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["db_user"], "usuario-editado",
"el PUT tiene que haber aplicado el usuario que sí informó: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["has_db_password"], true,
"la credencial conservada tiene que seguir anunciándose como presente: {cuerpo}"
);
}
#[tokio::test]
async fn un_put_que_cambia_el_tipo_a_directory_descarta_la_credencial() {
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);
let respuesta = post_json(
router.clone(),
"/api/v1/targets",
&cuerpo_de_target("target-1-32t-bis"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 con la credencial guardada antes de poder cambiarla \
de tipo"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(
cuerpo["data"]["has_db_password"], true,
"el alta de un target de base de datos tiene que dejar credencial antes del cambio de \
tipo: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let respuesta = put_json(
router,
&format!("/api/v1/targets/{id}"),
&serde_json::json!({
"name": "target-1-32t-bis",
"target_type": "directory",
"host_path": "/srv/datos",
}),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el cambio de tipo a `directory` es una actualización válida y tiene que contestar 200; \
contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["data"]["target_type"], "directory",
"la respuesta tiene que devolver el tipo que acaba de escribir el PUT: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["has_db_password"], false,
"un directorio no tiene credencial, así que la vista no puede anunciarla como \
presente: {cuerpo}"
);
assert!(
cuerpo["data"]["container_name"].is_null(),
"un `directory` no tiene contenedor, así que la columna tiene que quedar nula en la \
respuesta: {cuerpo}"
);
assert!(
cuerpo["data"]["db_user"].is_null(),
"un `directory` no tiene usuario de base, así que la columna tiene que quedar nula en \
la respuesta: {cuerpo}"
);
assert!(
cuerpo["data"]["db_name"].is_null(),
"un `directory` no tiene nombre de base, así que la columna tiene que quedar nula en \
la respuesta: {cuerpo}"
);
let guardada: Option<String> =
sqlx::query_scalar("SELECT db_password FROM backup_targets WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila actualizada tiene que seguir en el fichero de la base");
assert!(
guardada.is_none(),
"la credencial tiene que descartarse de verdad, no quedarse huérfana en la columna que \
el tipo nuevo ya no va a mirar: quedó {guardada:?}"
);
}
#[tokio::test]
async fn un_alta_sqlite_con_sus_campos_es_valida_y_escribe_la_ruta() {
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 cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-55",
"target_type": "sqlite",
"container_name": "contenedor-sqlite",
"db_path": "/var/lib/kesto/kesto.db",
});
let respuesta = post_json(
api_router(estado),
"/api/v1/targets",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"un `sqlite` con su contenedor y su ruta es un target completo: contestó {status} con \
{cuerpo}"
);
assert_eq!(
cuerpo["data"]["target_type"], "sqlite",
"el tipo tiene que volver como el texto del cable desde el que se pidió: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["db_path"], "/var/lib/kesto/kesto.db",
"el `db_path` tiene que volver en la respuesta, que es lo que el panel pinta al \
editar: {cuerpo}"
);
assert!(
cuerpo["data"]["db_user"].is_null() && cuerpo["data"]["host_path"].is_null(),
"un `sqlite` no tiene usuario de base ni ruta de host, así que sus campos tienen que \
quedar nulos: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["has_db_password"], false,
"un `sqlite` no tiene credencial de base: {cuerpo}"
);
let id = cuerpo["data"]["id"]
.as_str()
.expect("el alta debe devolver el identificador que generó la capa de acceso")
.to_owned();
let guardado: (String, String) =
sqlx::query_as("SELECT target_type, db_path FROM backup_targets WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila tiene que estar en el fichero con el que se abrió el estado");
assert_eq!(
guardado,
("sqlite".to_owned(), "/var/lib/kesto/kesto.db".to_owned()),
"la columna tiene que guardar el texto del enumerado y la ruta tal cual"
);
}
#[tokio::test]
async fn un_sqlite_sin_container_name_o_sin_db_path_se_rechaza_nombrando_el_campo() {
let (directorio, pool, clave) = base_con_clave().await;
let router = api_router(estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
));
for (campo, cuerpo_de_la_peticion) in [
(
"container_name",
serde_json::json!({
"name": "target-1-56-contenedor",
"target_type": "sqlite",
"db_path": "/var/lib/kesto/kesto.db",
}),
),
(
"db_path",
serde_json::json!({
"name": "target-1-56-ruta",
"target_type": "sqlite",
"container_name": "contenedor-sqlite",
}),
),
] {
let respuesta =
post_json(router.clone(), "/api/v1/targets", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `sqlite` sin `{campo}` está incompleto: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains(campo),
"el rechazo tiene que nombrar el campo que falta —`{campo}`—, y el mensaje fue \
{mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect("la tabla tiene que ser contable sobre el fichero del estado");
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
}
#[tokio::test]
async fn un_sqlite_con_campos_de_base_o_de_host_se_rechaza_nombrando_el_campo() {
let (directorio, pool, clave) = base_con_clave().await;
let router = api_router(estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), Arc::clone(&clave)),
));
for (campo, valor) in [
("db_user", serde_json::json!("usuario-de-base")),
("db_password", serde_json::json!(CONTRASENA_DE_TARGET)),
("db_name", serde_json::json!("base-de-pruebas")),
("host_path", serde_json::json!("/srv/datos")),
] {
let mut cuerpo_de_la_peticion = serde_json::json!({
"name": "target-1-57",
"target_type": "sqlite",
"container_name": "contenedor-sqlite",
"db_path": "/var/lib/kesto/kesto.db",
});
cuerpo_de_la_peticion[campo] = valor;
let respuesta =
post_json(router.clone(), "/api/v1/targets", &cuerpo_de_la_peticion).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un `sqlite` que informa `{campo}` es un formulario incoherente: contestó {status} \
con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains(campo),
"el rechazo tiene que nombrar el campo que lo provoca —`{campo}`—, y el mensaje \
fue {mensaje:?}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets")
.fetch_one(&pool)
.await
.expect("la tabla tiene que ser contable sobre el fichero del estado");
assert_eq!(
filas, 0,
"una petición rechazada no puede dejar fila escrita: quedaron {filas}"
);
}
}
}