From 65278709411d6af45a4e9e3b3d4277f29b9be246 Mon Sep 17 00:00:00 2001 From: Paul-Christian Volkmer Date: Tue, 15 Jul 2025 21:40:34 +0200 Subject: [PATCH] test: ensure authorization check is done first This will ensure authorization check will be done before any other checks. --- src/main.rs | 51 +++++++------------- src/routes.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 38 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2658e65..d3fba85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,16 @@ use axum::body::Body; -use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE}; -use axum::http::{HeaderValue, Request, StatusCode}; -use axum::middleware::{from_fn, Next}; +use axum::http::header::WWW_AUTHENTICATE; +use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use clap::Parser; use rdkafka::producer::FutureProducer; use rdkafka::ClientConfig; use serde::{Deserialize, Serialize}; use std::sync::{Arc, LazyLock}; -use tower_http::trace::TraceLayer; + +#[cfg(not(test))] +use clap::Parser; use crate::cli::Cli; -use crate::routes::routes; use crate::sender::DefaultMtbFileSender; use crate::AppResponse::{Accepted, Unauthorized, UnsupportedContentType}; @@ -55,6 +54,7 @@ impl IntoResponse for AppResponse<'_> { } } +#[cfg(not(test))] static CONFIG: LazyLock = LazyLock::new(Cli::parse); #[tokio::main] @@ -81,14 +81,10 @@ async fn main() -> Result<(), ()> { let sender = Arc::new(DefaultMtbFileSender::new(&CONFIG.topic, producer)); - let routes = routes(sender) - .layer(from_fn(check_basic_auth)) - .layer(TraceLayer::new_for_http()); - match tokio::net::TcpListener::bind(&CONFIG.listen).await { Ok(listener) => { log::info!("Starting application listening on '{}'", CONFIG.listen); - if let Err(err) = axum::serve(listener, routes).await { + if let Err(err) = axum::serve(listener, routes::routes(sender)).await { log::error!("Error starting application: {err}"); } } @@ -98,30 +94,15 @@ async fn main() -> Result<(), ()> { Ok(()) } -async fn check_content_type_header(request: Request, next: Next) -> Response { - match request - .headers() - .get(CONTENT_TYPE) - .map(HeaderValue::as_bytes) - { - Some( - b"application/json" - | b"application/json; charset=utf-8" - | b"application/vnd.dnpm.v2.mtb+json", - ) => next.run(request).await, - _ => UnsupportedContentType.into_response(), - } -} - -async fn check_basic_auth(request: Request, next: Next) -> Response { - if let Some(Ok(auth_header)) = request.headers().get(AUTHORIZATION).map(|x| x.to_str()) { - if auth::check_basic_auth(auth_header, &CONFIG.token) { - return next.run(request).await; - } - } - log::warn!("Invalid authentication used"); - Unauthorized.into_response() -} +// Test Configuration +#[cfg(test)] +static CONFIG: LazyLock = LazyLock::new(|| Cli { + bootstrap_server: "localhost:9094".to_string(), + topic: "test-topic".to_string(), + // Basic dG9rZW46dmVyeS1zZWNyZXQ= + token: "$2y$05$LIIFF4Rbi3iRVA4UIqxzPeTJ0NOn/cV2hDnSKFftAMzbEZRa42xSG".to_string(), + listen: "0.0.0.0:3000".to_string(), +}); #[cfg(test)] mod tests { diff --git a/src/routes.rs b/src/routes.rs index 3f3978f..a070647 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,12 +1,16 @@ -use crate::check_content_type_header; use crate::sender::DynMtbFileSender; -use crate::AppResponse::{Accepted, InternalServerError}; +use crate::AppResponse::{Accepted, InternalServerError, Unauthorized, UnsupportedContentType}; +use crate::{auth, CONFIG}; +use axum::body::Body; use axum::extract::Path; -use axum::middleware::from_fn; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{HeaderValue, Request}; +use axum::middleware::{from_fn, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, post}; use axum::{Extension, Json, Router}; use mv64e_mtb_dto::Mtb; +use tower_http::trace::TraceLayer; pub async fn handle_delete( Path(patient_id): Path, @@ -38,6 +42,33 @@ pub fn routes(sender: DynMtbFileSender) -> Router { ) .layer(Extension(sender)) .layer(from_fn(check_content_type_header)) + .layer(from_fn(check_basic_auth)) + .layer(TraceLayer::new_for_http()) +} + +async fn check_basic_auth(request: Request, next: Next) -> Response { + if let Some(Ok(auth_header)) = request.headers().get(AUTHORIZATION).map(|x| x.to_str()) { + if auth::check_basic_auth(auth_header, &CONFIG.token) { + return next.run(request).await; + } + } + log::warn!("Invalid authentication used"); + Unauthorized.into_response() +} + +async fn check_content_type_header(request: Request, next: Next) -> Response { + match request + .headers() + .get(CONTENT_TYPE) + .map(HeaderValue::as_bytes) + { + Some( + b"application/json" + | b"application/json; charset=utf-8" + | b"application/vnd.dnpm.v2.mtb+json", + ) => next.run(request).await, + _ => UnsupportedContentType.into_response(), + } } #[cfg(test)] @@ -68,6 +99,7 @@ mod tests { Request::builder() .method(Method::POST) .uri("/mtb/etl/patient-record") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") .header(CONTENT_TYPE, "application/json") .body(body) .expect("request built"), @@ -98,6 +130,7 @@ mod tests { Request::builder() .method(Method::DELETE) .uri("/mtb/etl/patient-record/fae56ea7-24a7-4556-82fb-2b5dde71bb4d") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") .header(CONTENT_TYPE, "application/json") .body(Body::empty()) .expect("request built"), @@ -126,6 +159,7 @@ mod tests { Request::builder() .method(Method::POST) .uri("/mtb/etl/patient-record") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") .header(CONTENT_TYPE, "application/vnd.dnpm.v2.mtb+json") .body(body) .expect("request built"), @@ -154,6 +188,7 @@ mod tests { Request::builder() .method(Method::POST) .uri("/mtb/etl/patient-record") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") .header(CONTENT_TYPE, "application/xml") .body(body) .expect("request built"), @@ -163,4 +198,91 @@ mod tests { assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); } + + #[tokio::test] + #[allow(clippy::expect_used)] + async fn should_respond_bad_request_if_not_parsable() { + let mut sender_mock = MockMtbFileSender::new(); + + sender_mock + .expect_send() + .withf(|mtb| mtb.patient.id.eq("fae56ea7-24a7-4556-82fb-2b5dde71bb4d")) + .return_once(move |_| Ok(String::new())); + + let router = routes(Arc::new(sender_mock) as DynMtbFileSender); + let body = Body::from("Das ist kein JSON!"); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/mtb/etl/patient-record") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") + .header(CONTENT_TYPE, "application/json") + .body(body) + .expect("request built"), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + #[allow(clippy::expect_used)] + async fn should_respond_bad_request_if_not_processable() { + let mut sender_mock = MockMtbFileSender::new(); + + sender_mock + .expect_send() + .withf(|mtb| mtb.patient.id.eq("fae56ea7-24a7-4556-82fb-2b5dde71bb4d")) + .return_once(move |_| Ok(String::new())); + + let router = routes(Arc::new(sender_mock) as DynMtbFileSender); + let body = Body::from("{}"); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/mtb/etl/patient-record") + .header(AUTHORIZATION, "Basic dG9rZW46dmVyeS1zZWNyZXQ=") + .header(CONTENT_TYPE, "application/json") + .body(body) + .expect("request built"), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + #[allow(clippy::expect_used)] + async fn should_check_authorization_first() { + let mut sender_mock = MockMtbFileSender::new(); + + sender_mock + .expect_send() + .withf(|mtb| mtb.patient.id.eq("fae56ea7-24a7-4556-82fb-2b5dde71bb4d")) + .return_once(move |_| Ok(String::new())); + + let router = routes(Arc::new(sender_mock) as DynMtbFileSender); + let body = Body::from("Das ist ein Test"); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/mtb/etl/patient-record") + // No Auth header! + .header(CONTENT_TYPE, "application/xml") + .body(body) + .expect("request built"), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } }