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::extract::Json;
use crate::api::response::{ApiResponse, Data};
use crate::api::{contenido, del_motor_en_el_alta};
use crate::db::entities::{AltaDeRepositorio, Repositorio};
use crate::error::AppError;
use crate::rustic::{Acceso, PuestaEnMarcha};
use crate::secret::SecretString;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/repositories", get(listado).post(alta))
.route(
"/api/v1/repositories/{id}",
get(leer).put(actualizar).delete(borrar),
)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AltaDeRepositorioDelCable {
pub name: String,
pub endpoint: String,
pub bucket: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: Option<SecretString>,
pub passphrase: Option<SecretString>,
}
#[derive(Debug, Serialize)]
pub struct RepositorioDelCable {
pub id: String,
pub name: String,
pub endpoint: String,
pub bucket: String,
pub region: String,
pub access_key_id: String,
pub has_secret_access_key: bool,
pub has_passphrase: bool,
pub created_at: String,
}
impl RepositorioDelCable {
fn desde(fila: &Repositorio) -> Result<Self, AppError> {
Ok(Self {
id: fila.id.hyphenated().to_string(),
name: fila.name.clone(),
endpoint: fila.endpoint.clone(),
bucket: fila.bucket.clone(),
region: fila.region.clone(),
access_key_id: fila.access_key_id.clone(),
has_secret_access_key: !fila.secret_access_key.expose().is_empty(),
has_passphrase: !fila.passphrase.expose().is_empty(),
created_at: fila.created_at.format(&Rfc3339).map_err(|error| {
AppError::Other(format!("la fecha de la fila no se pudo formatear: {error}"))
})?,
})
}
}
#[derive(Debug, Serialize)]
struct RepositorioCreadoDelCable {
#[serde(flatten)]
repositorio: RepositorioDelCable,
outcome: &'static str,
}
impl RepositorioCreadoDelCable {
fn desde(fila: &Repositorio, puesta: PuestaEnMarcha) -> Result<Self, AppError> {
Ok(Self {
repositorio: RepositorioDelCable::desde(fila)?,
outcome: desenlace(puesta),
})
}
}
fn desenlace(puesta: PuestaEnMarcha) -> &'static str {
match puesta {
PuestaEnMarcha::Inicializado => "initialized",
PuestaEnMarcha::Conectado => "connected",
}
}
fn acceso_del_alta(alta: &AltaDeRepositorio) -> Acceso {
Acceso {
endpoint: alta.endpoint.clone(),
bucket: alta.bucket.clone(),
region: alta.region.clone(),
access_key_id: alta.access_key_id.clone(),
secret_access_key: alta.secret_access_key.clone(),
passphrase: alta.passphrase.clone(),
}
}
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 repositorio_ausente() -> AppError {
AppError::NotFound("no existe ningún repositorio con ese identificador".to_owned())
}
fn resolver_secreto(
nuevo: Option<SecretString>,
almacenado: SecretString,
campo: &'static str,
) -> Result<SecretString, AppError> {
match nuevo {
None => Ok(almacenado),
Some(valor) if valor.expose().is_empty() => Err(AppError::InvalidInput(format!(
"el campo `{campo}` no puede ir vacío"
))),
Some(valor) => Ok(valor),
}
}
pub async fn listado(State(state): State<AppState>) -> Result<ApiResponse, AppError> {
let filas = state.db.repositorios.listar().await?;
let vistas: Vec<RepositorioDelCable> = filas
.iter()
.map(RepositorioDelCable::desde)
.collect::<Result<_, _>>()?;
Ok(ApiResponse::new(
StatusCode::OK,
"repositorios listados",
contenido(&vistas)?,
))
}
pub async fn alta(
State(state): State<AppState>,
Json(cuerpo): Json<AltaDeRepositorioDelCable>,
) -> Result<ApiResponse, AppError> {
let Some(secret_access_key) = cuerpo.secret_access_key else {
return Err(AppError::InvalidInput(
"el campo `secret_access_key` es obligatorio en un alta".to_owned(),
));
};
let Some(passphrase) = cuerpo.passphrase else {
return Err(AppError::InvalidInput(
"el campo `passphrase` es obligatorio en un alta".to_owned(),
));
};
let alta = AltaDeRepositorio {
name: cuerpo.name,
endpoint: cuerpo.endpoint,
bucket: cuerpo.bucket,
region: cuerpo.region,
access_key_id: cuerpo.access_key_id,
secret_access_key,
passphrase,
};
let puesta = state
.motor
.abrir_repositorio(acceso_del_alta(&alta))
.await
.map_err(del_motor_en_el_alta)?;
let fila = state.db.repositorios.alta(alta).await?;
let vista = RepositorioCreadoDelCable::desde(&fila, puesta)?;
Ok(ApiResponse::new(
StatusCode::CREATED,
"repositorio 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.repositorios.leer(&uuid).await? else {
return Err(repositorio_ausente());
};
let vista = RepositorioDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"repositorio encontrado",
contenido(&vista)?,
))
}
pub async fn actualizar(
State(state): State<AppState>,
Path(id): Path<String>,
Json(cuerpo): Json<AltaDeRepositorioDelCable>,
) -> Result<ApiResponse, AppError> {
let uuid = identificador(&id)?;
let Some(actual) = state.db.repositorios.leer(&uuid).await? else {
return Err(repositorio_ausente());
};
let fila = Repositorio {
id: actual.id,
name: cuerpo.name,
endpoint: cuerpo.endpoint,
bucket: cuerpo.bucket,
region: cuerpo.region,
access_key_id: cuerpo.access_key_id,
secret_access_key: resolver_secreto(
cuerpo.secret_access_key,
actual.secret_access_key,
"secret_access_key",
)?,
passphrase: resolver_secreto(cuerpo.passphrase, actual.passphrase, "passphrase")?,
created_at: actual.created_at,
};
if !state.db.repositorios.actualizar(&fila).await? {
return Err(repositorio_ausente());
}
let vista = RepositorioDelCable::desde(&fila)?;
Ok(ApiResponse::new(
StatusCode::OK,
"repositorio 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.repositorios.borrar(&uuid).await? {
return Err(repositorio_ausente());
}
Ok(ApiResponse::new(
StatusCode::OK,
"repositorio borrado",
Data::None,
))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use axum::http::{StatusCode, header};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::api::api_router;
use crate::state::EstadoDeBase;
use crate::test_support::{
OTRA_PASSPHRASE_DE_PRUEBA, PASSPHRASE_DE_PRUEBA, abrir_local, base_con_clave, body_json,
body_text, capturar_log, config_con_dsn, delete, destino_local_de_prueba,
directorio_de_prueba, estado_de_prueba, estado_desarrollo, get, post_crudo, post_json,
put_json, sembrar_snapshots,
};
const CREDENCIAL_DE_RECHAZO: &str = "AKIAIOSFODNN7EXAMPLE-credencial-distinguible-0001";
#[tokio::test]
async fn dos_peticiones_seguidas_al_mismo_router_ven_la_misma_fila() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-2"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado"
);
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, &format!("/api/v1/repositories/{id}")).await;
assert_ne!(
respuesta.status(),
StatusCode::NOT_FOUND,
"la segunda petición no ve la fila que escribió la primera: cada una abrió su base"
);
assert_eq!(respuesta.status(), StatusCode::OK);
let cuerpo = body_json(respuesta).await;
assert_eq!(
cuerpo["data"]["name"], "repo-1-2",
"la lectura tiene que devolver la fila que escribió el alta: {cuerpo}"
);
}
#[tokio::test]
async fn la_escritura_por_el_router_esta_en_el_fichero_de_la_base() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), clave),
);
let cuerpo_de_la_peticion = cuerpo_de_alta("repo-1-3");
let endpoint_informado = cuerpo_de_la_peticion["endpoint"]
.as_str()
.expect("el cuerpo de la prueba informa el endpoint")
.to_owned();
let respuesta = post_json(
api_router(estado),
"/api/v1/repositories",
&cuerpo_de_la_peticion,
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado"
);
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 nombre: String = sqlx::query_scalar("SELECT name FROM repositories 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!(
nombre, "repo-1-3",
"la fila del fichero no tiene los valores que informó la petición"
);
let endpoint: String =
sqlx::query_scalar("SELECT endpoint FROM repositories 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!(
endpoint, endpoint_informado,
"la fila del fichero no tiene los valores que informó la petición"
);
}
#[tokio::test]
async fn un_cuerpo_que_no_es_json_contesta_400_con_la_envoltura() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_crudo(router, "/api/v1/repositories", "esto no es json").await;
let status = respuesta.status();
let tipo_de_contenido = respuesta
.headers()
.get(header::CONTENT_TYPE)
.and_then(|valor| valor.to_str().ok())
.map(ToOwned::to_owned);
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un cuerpo que no se puede interpretar tiene que contestar 400 por la envoltura, \
no el código con el que lo rechaza el intérprete"
);
let tipo_de_contenido = tipo_de_contenido
.expect("la respuesta tiene que declarar un tipo de contenido, no salir sin cabecera");
assert!(
tipo_de_contenido.starts_with("application/json"),
"el rechazo no puede salir en texto plano: publicaría el texto del intérprete, \
que ecoa el valor del campo; el tipo de contenido fue {tipo_de_contenido}"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(
cuerpo["status"], 400,
"la envoltura tiene que repetir el código en `status`: {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
!mensaje.is_empty(),
"la envoltura tiene que explicar el rechazo, no traer un mensaje vacío: {cuerpo}"
);
}
#[tokio::test]
async fn el_rechazo_de_un_cuerpo_no_publica_la_credencial_en_ninguna_parte() {
let (buffer, _guard) = capturar_log();
let router = api_router(estado_desarrollo().await);
let vectores = [
(
"el cuerpo entero es la credencial",
format!("\"{CREDENCIAL_DE_RECHAZO}\""),
),
(
"la credencial es el nombre de un campo que no existe",
format!("{{\"{CREDENCIAL_DE_RECHAZO}\":1}}"),
),
];
for (motivo, cuerpo_de_la_peticion) in vectores {
let respuesta = post_crudo(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_la_peticion,
)
.await;
let status = respuesta.status();
let texto = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"el rechazo tiene que salir por la envoltura ({motivo}); contestó {status} \
con {texto}"
);
assert!(
!texto.contains(CREDENCIAL_DE_RECHAZO),
"el rechazo publicó la credencial al cliente ({motivo}): {texto}"
);
}
let registrado = buffer.texto();
assert!(
!registrado.contains(CREDENCIAL_DE_RECHAZO),
"el rechazo escribió la credencial en el registro ({registrado})"
);
assert!(
registrado.contains("no se pudo interpretar"),
"tiene que quedar registrado que una petición no se pudo interpretar —sin su \
texto—, y el registro capturado fue: {registrado}"
);
}
#[tokio::test]
async fn las_cinco_rutas_del_recurso_contestan_su_codigo() {
let router = api_router(estado_desarrollo().await).clone();
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-5"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 con el recurso creado"
);
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/repositories").await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"el listado tiene que contestar 200 con el array de repositorios"
);
let respuesta = get(router.clone(), &format!("/api/v1/repositories/{id}")).await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"la lectura de una fila que se acaba de dar de alta tiene que contestar 200"
);
let respuesta = put_json(
router.clone(),
&format!("/api/v1/repositories/{id}"),
&cuerpo_de_alta("repo-1-5-modificado"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"la actualización de una fila existente tiene que contestar 200"
);
let respuesta = delete(router, &format!("/api/v1/repositories/{id}")).await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"el borrado de una fila existente tiene que contestar 200"
);
}
#[tokio::test]
async fn el_listado_contesta_200_con_un_array_en_data() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-7"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 antes de poder listar nada"
);
let respuesta = get(router, "/api/v1/repositories").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"el listado tiene que contestar 200; contestó {status} con {cuerpo}"
);
let filas = cuerpo["data"].as_array().unwrap_or_else(|| {
panic!("el listado tiene que traer un array en `data`, y llegó {cuerpo}")
});
assert!(
filas.iter().any(|fila| fila["name"] == "repo-1-7"),
"el listado tiene que incluir la fila que se acaba de dar de alta, y llegó {cuerpo}"
);
}
#[tokio::test]
async fn una_tabla_vacia_contesta_200_con_array_vacio() {
let router = api_router(estado_desarrollo().await);
let respuesta = get(router, "/api/v1/repositories").await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"una tabla sin filas es un listado vacío, no un recurso ausente: contestó \
{status} con {cuerpo}"
);
let filas = cuerpo["data"]
.as_array()
.unwrap_or_else(|| panic!("el listado vacío tiene que traer un array en `data`, no un `null`, y llegó {cuerpo}"));
assert!(
filas.is_empty(),
"sin altas, el listado tiene que traer un array vacío, y llegó {cuerpo}"
);
}
#[tokio::test]
async fn el_delete_contesta_200_con_data_nulo_y_la_lectura_posterior_404() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-11"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 antes de poder borrar 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 respuesta = delete(router.clone(), &format!("/api/v1/repositories/{id}")).await;
let status = respuesta.status();
assert_ne!(
status,
StatusCode::NO_CONTENT,
"el borrado no puede contestar 204: un 204 no tiene cuerpo y rompería la \
envoltura de la API"
);
assert_eq!(
status,
StatusCode::OK,
"el borrado tiene que contestar 200 con la envoltura; contestó {status}"
);
let cuerpo = body_json(respuesta).await;
assert!(
cuerpo["data"].is_null(),
"el borrado no tiene recurso que devolver: `data` tiene que ser nulo, y llegó \
{cuerpo}"
);
assert_eq!(
cuerpo["status"], 200,
"la envoltura tiene que repetir el código en `status`: {cuerpo}"
);
let respuesta = get(router, &format!("/api/v1/repositories/{id}")).await;
assert_eq!(
respuesta.status(),
StatusCode::NOT_FOUND,
"la fila borrada no puede seguir leyéndose: la lectura tiene que contestar 404"
);
}
#[tokio::test]
async fn el_put_sobre_una_fila_inexistente_contesta_404() {
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 id = Uuid::new_v4().to_string();
let respuesta = put_json(
api_router(estado),
&format!("/api/v1/repositories/{id}"),
&cuerpo_de_alta("repo-1-12"),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"un PUT sobre una fila que no existe tiene que contestar 404; contestó {status} \
con {cuerpo}"
);
assert_eq!(
cuerpo["status"], 404,
"la envoltura tiene que repetir el código en `status`: {cuerpo}"
);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 404 tiene que explicar que no hay fila que actualizar: {cuerpo}"
);
let filas: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM repositories")
.fetch_one(&pool)
.await
.expect("la cuenta de filas tiene que poder leerse del fichero de la base");
assert_eq!(
filas, 0,
"un PUT sobre una fila inexistente no puede escribir ninguna fila: quedaron \
{filas}"
);
}
#[tokio::test]
async fn un_put_sin_passphrase_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/repositories",
&cuerpo_de_alta("repo-1-32"),
)
.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: String =
sqlx::query_scalar("SELECT passphrase FROM repositories 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 passphrase_antes = clave
.descifrar(&sobre_antes)
.expect("el alta tiene que haber guardado la passphrase cifrada y descifrable")
.expose()
.to_owned();
let respuesta = put_json(
router.clone(),
&format!("/api/v1/repositories/{id}"),
&serde_json::json!({
"name": "repo-1-32-modificado",
"endpoint": "https://s3.example.test/repo-1-32-modificado",
"bucket": "cubo-modificado",
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
}),
)
.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: String =
sqlx::query_scalar("SELECT passphrase FROM repositories WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila actualizada tiene que seguir en el fichero de la base");
let passphrase_despues = clave
.descifrar(&sobre_despues)
.expect("la actualización tiene que dejar la passphrase cifrada y descifrable")
.expose()
.to_owned();
assert_eq!(
passphrase_despues, passphrase_antes,
"un PUT que no informa la passphrase no puede borrarla: el panel no tiene el \
valor —la API no lo devuelve— y sin esta regla reescribiría la credencial con \
nada"
);
let nombre: String = sqlx::query_scalar("SELECT name FROM repositories WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila actualizada tiene que seguir en el fichero de la base");
assert_eq!(
nombre, "repo-1-32-modificado",
"el PUT tiene que haber aplicado los campos que sí informó"
);
let respuesta = get(router, &format!("/api/v1/repositories/{id}")).await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"la fila actualizada tiene que seguir leyéndose por la API"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(
cuerpo["data"]["name"], "repo-1-32-modificado",
"la lectura tiene que devolver el nombre que escribió el PUT: {cuerpo}"
);
}
#[tokio::test]
async fn un_put_con_la_passphrase_vacia_se_rechaza_sin_cambiar_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/repositories",
&cuerpo_de_alta("repo-1-33"),
)
.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: String =
sqlx::query_scalar("SELECT passphrase FROM repositories 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 passphrase_antes = clave
.descifrar(&sobre_antes)
.expect("el alta tiene que haber guardado la passphrase cifrada y descifrable")
.expose()
.to_owned();
let mut cuerpo_de_la_actualizacion = cuerpo_de_alta("repo-1-33");
cuerpo_de_la_actualizacion["passphrase"] = serde_json::Value::String(String::new());
let respuesta = put_json(
router,
&format!("/api/v1/repositories/{id}"),
&cuerpo_de_la_actualizacion,
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"un secreto presente y vacío es un intento de dejarlo en blanco, y eso se \
rechaza: contestó {status} con {cuerpo}"
);
let mensaje = cuerpo["message"]
.as_str()
.expect("la envoltura tiene que traer un `message` de texto: {cuerpo}");
assert!(
mensaje.contains("passphrase"),
"el rechazo tiene que nombrar el campo que lo provoca, y el mensaje fue \
{mensaje:?}"
);
let sobre_despues: String =
sqlx::query_scalar("SELECT passphrase FROM repositories WHERE id = ?1")
.bind(&id)
.fetch_one(&pool)
.await
.expect("la fila rechazada tiene que seguir en el fichero de la base");
let passphrase_despues = clave
.descifrar(&sobre_despues)
.expect("el rechazo no puede haber dejado la passphrase indescifrable")
.expose()
.to_owned();
assert_eq!(
passphrase_despues, passphrase_antes,
"el rechazo no puede haber cambiado nada: la passphrase almacenada tiene que \
seguir siendo la del alta"
);
}
fn cuerpo_de_alta(nombre: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"endpoint": destino_local_de_prueba(nombre),
"bucket": format!("cubo-{nombre}"),
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "AKIAIOSFODNN7EXAMPLE-secreto-de-prueba",
"passphrase": "passphrase-distinguible-00000000000000000000000001",
})
}
#[tokio::test]
async fn un_get_de_una_fila_inexistente_contesta_404_con_la_envoltura() {
let router = api_router(estado_desarrollo().await);
let id = Uuid::new_v4().to_string();
let respuesta = get(router, &format!("/api/v1/repositories/{id}")).await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"un GET sobre una fila que no existe tiene que contestar 404; contestó {status} \
con {cuerpo}"
);
assert_eq!(
cuerpo["status"], 404,
"la envoltura tiene que repetir el código en `status`: {cuerpo}"
);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 404 tiene que explicar que no hay fila que leer: {cuerpo}"
);
}
#[tokio::test]
async fn el_identificador_del_cable_es_la_forma_canonica_con_guiones() {
let router = api_router(estado_desarrollo().await);
let respuesta = post_json(
router.clone(),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-14"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 antes de poder mirar el identificador"
);
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 uuid = Uuid::parse_str(&id).expect(
"el identificador tiene que poder releerse como UUID —el panel lo usa para \
construir las rutas de GET, PUT y DELETE—",
);
assert_eq!(
uuid.hyphenated().to_string(),
id,
"el identificador releído tiene que volver a escribirse igual: {id}"
);
let posiciones: Vec<usize> = id
.char_indices()
.filter(|(_, caracter)| *caracter == '-')
.map(|(indice, _)| indice)
.collect();
assert_eq!(
posiciones,
vec![8, 13, 18, 23],
"los guiones tienen que estar en las posiciones de la forma canónica: {id}"
);
assert_eq!(
id.len(),
36,
"la forma canónica con guiones mide 36 bytes, y éste mide {}: {id}",
id.len()
);
let respuesta = get(router, &format!("/api/v1/repositories/{id}")).await;
assert_eq!(
respuesta.status(),
StatusCode::OK,
"el identificador que salió del alta tiene que servir para pedir la fila: {id}"
);
}
#[tokio::test]
async fn la_fecha_del_cable_se_relee_con_rfc3339_y_es_el_mismo_instante() {
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 respuesta = post_json(
api_router(estado.clone()),
"/api/v1/repositories",
&cuerpo_de_alta("repo-1-15"),
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::CREATED,
"el alta tiene que contestar 201 antes de poder mirar la fecha"
);
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 texto = cuerpo["data"]["created_at"]
.as_str()
.expect(
"la fecha del cable tiene que ser una **cadena** RFC3339 y no un número: \
`time` sin `serde-human-readable` serializa por defecto en su forma \
binaria, y una fecha numérica delataría ese cambio",
)
.to_owned();
let instante_publicado = OffsetDateTime::parse(&texto, &Rfc3339).expect(
"la fecha del cable tiene que releerse con RFC3339, que es el formato que el \
contrato fija",
);
assert!(
texto.ends_with('Z'),
"la fecha del cable termina en `Z` cuando el instante va en UTC: {texto}"
);
assert!(
!texto.contains('.'),
"la fecha del cable omite la fracción de segundo cuando los nanosegundos son \
cero, y ésta la lleva: {texto}"
);
let uuid = Uuid::parse_str(&id).expect("el identificador del alta tiene que ser un UUID");
let fila = estado
.db
.repositorios
.leer(&uuid)
.await
.expect("la fila recién dada de alta tiene que poder releerse")
.expect("la fila recién dada de alta tiene que seguir en la base");
assert_eq!(
fila.created_at, instante_publicado,
"la fecha que publica la API tiene que ser la que guardó la base, y la base \
guardó {} mientras el cable publicó {texto}",
fila.created_at
);
}
#[tokio::test]
async fn el_alta_y_la_lectura_no_traen_los_secretos_y_si_los_booleanos() {
fn comprobar_vista(
etiqueta: &str,
cuerpo: &serde_json::Value,
texto: &str,
secretos: [&str; 2],
) {
let data = &cuerpo["data"];
for (campo, presente) in [
(
"has_secret_access_key",
data["has_secret_access_key"].as_bool(),
),
("has_passphrase", data["has_passphrase"].as_bool()),
] {
assert_eq!(
presente,
Some(true),
"el {etiqueta} tiene que decir que `{campo}` está guardada: {cuerpo}"
);
}
for campo in ["secret_access_key", "passphrase"] {
assert!(
data[campo].is_null(),
"el {etiqueta} no puede traer `{campo}`: de un secreto guardado sale un \
booleano y nunca el valor: {cuerpo}"
);
}
for secreto in secretos {
assert!(
!texto.contains(secreto),
"el {etiqueta} no puede contener el secreto ni dentro de otro campo, y \
el texto crudo lo lleva: {texto}"
);
}
}
let router = api_router(estado_desarrollo().await);
let cuerpo_de_peticion = cuerpo_de_alta("repo-1-16");
let secreto = cuerpo_de_peticion["secret_access_key"]
.as_str()
.expect("el cuerpo de alta del ayudante tiene que traer `secret_access_key`")
.to_owned();
let passphrase = cuerpo_de_peticion["passphrase"]
.as_str()
.expect("el cuerpo de alta del ayudante tiene que traer `passphrase`")
.to_owned();
let respuesta =
post_json(router.clone(), "/api/v1/repositories", &cuerpo_de_peticion).await;
let status = respuesta.status();
let texto_del_alta = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el alta tiene que contestar 201; contestó {status} con {texto_del_alta}"
);
let cuerpo_del_alta: serde_json::Value = serde_json::from_str(&texto_del_alta)
.expect("el cuerpo del alta tiene que ser JSON válido");
comprobar_vista(
"alta",
&cuerpo_del_alta,
&texto_del_alta,
[&secreto, &passphrase],
);
let id = cuerpo_del_alta["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/repositories/{id}")).await;
let status = respuesta.status();
let texto_de_la_lectura = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::OK,
"la lectura de la fila que se acaba de dar de alta tiene que contestar 200; \
contestó {status} con {texto_de_la_lectura}"
);
let cuerpo_de_la_lectura: serde_json::Value = serde_json::from_str(&texto_de_la_lectura)
.expect("el cuerpo de la lectura tiene que ser JSON válido");
comprobar_vista(
"lectura",
&cuerpo_de_la_lectura,
&texto_de_la_lectura,
[&secreto, &passphrase],
);
}
const SECRETO_DEL_ALTA_1_31: &str = "AKIAIOSFODNN7EXAMPLE-secreto-del-alta-1-31";
fn cuerpo_contra(nombre: &str, endpoint: &str, passphrase: &str) -> serde_json::Value {
serde_json::json!({
"name": nombre,
"endpoint": endpoint,
"bucket": format!("cubo-{nombre}"),
"region": "eu-west-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": SECRETO_DEL_ALTA_1_31,
"passphrase": passphrase,
})
}
async fn filas_de_repositorios(pool: &sqlx::SqlitePool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM repositories")
.fetch_one(pool)
.await
.expect("la tabla de repositorios tiene que poder contarse")
}
fn inventario(ruta: &Path) -> Vec<(String, Vec<u8>)> {
let mut encontrado = Vec::new();
let mut pendientes = vec![ruta.to_path_buf()];
while let Some(actual) = pendientes.pop() {
let Ok(entradas) = std::fs::read_dir(&actual) else {
continue;
};
for entrada in entradas.flatten() {
let camino = entrada.path();
if camino.is_dir() {
pendientes.push(camino);
} else if let Ok(contenido) = std::fs::read(&camino) {
encontrado.push((camino.to_string_lossy().into_owned(), contenido));
}
}
}
encontrado.sort_by(|uno, otro| uno.0.cmp(&otro.0));
encontrado
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_alta_contra_un_destino_vacio_lo_inicializa() {
let router = api_router(estado_desarrollo().await);
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
let respuesta = post_json(
router,
"/api/v1/repositories",
&cuerpo_contra("repo-1-31a", &ruta, PASSPHRASE_DE_PRUEBA),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el alta contra un destino vacío tiene que contestar 201; contestó {status} con \
{cuerpo}"
);
assert_eq!(
cuerpo["data"]["outcome"], "initialized",
"la respuesta del alta tiene que decir que el repositorio se ha inicializado, y \
llegó {cuerpo}"
);
let _ = abrir_local(destino.ruta(), PASSPHRASE_DE_PRUEBA);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_alta_contra_un_destino_con_repositorio_conecta() {
let router = api_router(estado_desarrollo().await);
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
let antes = inventario(destino.ruta());
sembrar_snapshots(
destino.ruta(),
PASSPHRASE_DE_PRUEBA,
&["2026-01-01T10:00:00Z"],
);
let respuesta = post_json(
router,
"/api/v1/repositories",
&cuerpo_contra("repo-1-31b", &ruta, PASSPHRASE_DE_PRUEBA),
)
.await;
let status = respuesta.status();
let cuerpo = body_json(respuesta).await;
assert_eq!(
status,
StatusCode::CREATED,
"el alta contra un destino que ya tiene repositorio tiene que contestar 201 y no \
fallar por «ya existe»; contestó {status} con {cuerpo}"
);
assert_eq!(
cuerpo["data"]["outcome"], "connected",
"la respuesta del alta tiene que decir que se ha conectado con un repositorio que ya \
estaba, y llegó {cuerpo}"
);
let despues = inventario(destino.ruta());
assert!(
despues.starts_with(&antes),
"el alta borró o reescribió ficheros del repositorio que ya estaba: de {} a {} \
entradas",
antes.len(),
despues.len()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_alta_contra_un_destino_inalcanzable_contesta_error_y_no_deja_fila() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), clave),
);
let ruta = destino_local_de_prueba("repo-1-31c");
std::fs::write(&ruta, b"esto no es un repositorio").expect("el fichero del destino");
let respuesta = post_json(
api_router(estado),
"/api/v1/repositories",
&cuerpo_contra("repo-1-31c", &ruta, PASSPHRASE_DE_PRUEBA),
)
.await;
let status = respuesta.status();
let texto = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_GATEWAY,
"un destino que no responde es un fallo del servicio de fuera, no de la petición: \
contestó {status} con {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el error tiene que salir por la envoltura");
assert_eq!(
cuerpo["status"], 502,
"la envoltura tiene que repetir el código: {texto}"
);
assert_eq!(
filas_de_repositorios(&pool).await,
0,
"un alta que no pudo hablar con el destino dejó una fila: el panel lo listaría como \
disponible y el fallo saldría en la primera copia"
);
assert!(
!texto.contains(SECRETO_DEL_ALTA_1_31),
"el error publicó la clave secreta que iba en la petición: {texto}"
);
assert!(
!texto.contains(PASSPHRASE_DE_PRUEBA),
"el error publicó la passphrase que iba en la petición: {texto}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn un_alta_cuya_passphrase_no_abre_no_deja_fila_ni_toca_el_destino() {
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), clave),
);
let destino = directorio_de_prueba();
let ruta = destino.ruta().to_string_lossy().into_owned();
sembrar_snapshots(
destino.ruta(),
PASSPHRASE_DE_PRUEBA,
&["2026-01-01T10:00:00Z"],
);
let antes = inventario(destino.ruta());
let respuesta = post_json(
api_router(estado),
"/api/v1/repositories",
&cuerpo_contra("repo-1-31d", &ruta, OTRA_PASSPHRASE_DE_PRUEBA),
)
.await;
let status = respuesta.status();
let texto = body_text(respuesta).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"una passphrase que no abre el repositorio que ya está es un fallo del formulario, no \
del servicio de fuera: contestó {status} con {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el error tiene que salir por la envoltura");
assert_eq!(cuerpo["status"], 400, "la envoltura del 400: {texto}");
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 400 tiene que explicar que la passphrase no abre: {texto}"
);
assert_eq!(
filas_de_repositorios(&pool).await,
0,
"el alta con la passphrase equivocada dejó fila"
);
assert_eq!(
inventario(destino.ruta()),
antes,
"el alta con la passphrase equivocada modificó el destino"
);
assert!(
!texto.contains(SECRETO_DEL_ALTA_1_31),
"el error publicó la clave secreta que iba en la petición: {texto}"
);
}
}