diff --git a/contract/Cargo.toml b/contract/Cargo.toml index e91debe..335d4c0 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] # Web framework axum = "0.7" +http-body-util = "0.1" tokio = { version = "1.35", features = ["full"] } tower = "0.4" tower-http = { version = "0.5", features = ["trace", "cors"] } @@ -48,4 +49,4 @@ async-trait = "0.1" [dev-dependencies] httpmock = "0.7" -axum-test = "16.4.1" +axum-test = "16.4.1" \ No newline at end of file diff --git a/contract/src/lib.rs b/contract/src/lib.rs index d9e34af..71f8512 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -25,6 +25,7 @@ use tracing::{info, warn}; use cache::CacheBackend; use hash_validator::{HashValidator, ValidationError as HashValidationError}; use metrics::MetricsRegistry; +use module::middleware::hash_normalization::normalize; use module::webhook::VerificationWebhookNotifier; use stellar::{StellarClient, TransactionRecord}; @@ -39,7 +40,7 @@ pub struct AppState { } // Request/Response types -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct VerifyRequest { pub document_hash: String, pub transaction_id: Option, @@ -54,7 +55,7 @@ pub struct VerifyResponse { } /// Request type for submitting a document hash to Stellar blockchain -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct SubmitRequest { pub document_hash: String, pub document_id: String, @@ -70,7 +71,7 @@ pub struct SubmitResponse { pub error: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct RevokeRequest { pub document_hash: String, pub reason: String, @@ -104,7 +105,7 @@ pub struct ValidationErrorResponse { pub error: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct BatchVerifyRequest { pub hashes: Vec, } @@ -126,7 +127,7 @@ pub struct BatchVerifyItem { pub error: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct TransferRequest { pub document_hash: String, pub from_owner: String, @@ -176,6 +177,8 @@ fn map_validation_error(err: HashValidationError) -> (StatusCode, ValidationErro } pub fn app(state: AppState) -> Router { + use crate::module::middleware::hash_normalization::hash_normalization_middleware; + Router::new() .route("/health", get(health_check)) .route("/metrics", get(metrics_handler)) @@ -186,6 +189,7 @@ pub fn app(state: AppState) -> Router { .route("/submit", post(submit_document)) .route("/revoke", post(revoke_document)) .route("/transfer", post(record_transfer)) + .layer(axum::middleware::from_fn(hash_normalization_middleware)) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -337,7 +341,7 @@ pub async fn verify_document( State(state): State, Json(req): Json, ) -> Response { - let normalized_hash = HashValidator::normalize(&req.document_hash); + let normalized_hash = normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); return (status, Json(body)).into_response(); @@ -397,7 +401,7 @@ pub async fn verify_document_history( State(state): State, Path(hash): Path, ) -> Response { - let normalized_hash = HashValidator::normalize(&hash); + let normalized_hash = normalize(&hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); return (status, Json(body)).into_response(); @@ -483,8 +487,7 @@ pub async fn batch_verify_documents( // Helper function to verify a single hash async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { - let normalized_hash = HashValidator::normalize(&hash); - + let normalized_hash = normalize(&hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let error_msg = match err { HashValidationError::EmptyHash => "hash must not be empty".to_string(), @@ -572,7 +575,7 @@ pub async fn submit_document( State(state): State, Json(req): Json, ) -> impl IntoResponse { - let normalized_hash = HashValidator::normalize(&req.document_hash); + let normalized_hash = normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); return (status, Json(body)).into_response(); @@ -639,7 +642,7 @@ pub async fn revoke_document( State(state): State, Json(req): Json, ) -> impl IntoResponse { - let normalized_hash = HashValidator::normalize(&req.document_hash); + let normalized_hash = normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); return (status, Json(body)).into_response(); @@ -691,7 +694,7 @@ pub async fn revoke_document( } pub async fn transfer_document(Json(req): Json) -> impl IntoResponse { - let normalized_hash = HashValidator::normalize(&req.document_hash); + let normalized_hash = normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { let (status, body) = map_validation_error(err); return (status, Json(body)); diff --git a/contract/src/module/middleware/hash_normalization.rs b/contract/src/module/middleware/hash_normalization.rs new file mode 100644 index 0000000..a423286 --- /dev/null +++ b/contract/src/module/middleware/hash_normalization.rs @@ -0,0 +1,229 @@ +use crate::hash_validator::{HashValidator, ValidationError}; +use crate::ValidationErrorResponse; +use axum::{ + extract::Request, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Json, Response}, +}; +use http_body_util::BodyExt; +use serde::Deserialize; + +/// Normalizes a hash string by trimming whitespace and converting to lowercase +pub fn normalize(hash: &str) -> String { + hash.trim().to_lowercase() +} + +/// Helper function to normalize hash fields in request bodies +pub fn normalize_request_body(body: &mut T) +where + T: HasDocumentHash, +{ + if let Some(document_hash) = body.document_hash_mut() { + *document_hash = normalize(document_hash); + } +} + +/// Trait to extract and mutate the document_hash field from request types +pub trait HasDocumentHash { + fn document_hash(&self) -> Option<&str>; + fn document_hash_mut(&mut self) -> Option<&mut String>; +} + +// Implement HasDocumentHash for all request types that have a document_hash field +#[derive(Debug, Deserialize)] +struct AnyRequest; + +impl HasDocumentHash for crate::VerifyRequest { + fn document_hash(&self) -> Option<&str> { + Some(&self.document_hash) + } + fn document_hash_mut(&mut self) -> Option<&mut String> { + Some(&mut self.document_hash) + } +} + +impl HasDocumentHash for crate::SubmitRequest { + fn document_hash(&self) -> Option<&str> { + Some(&self.document_hash) + } + fn document_hash_mut(&mut self) -> Option<&mut String> { + Some(&mut self.document_hash) + } +} + +impl HasDocumentHash for crate::RevokeRequest { + fn document_hash(&self) -> Option<&str> { + Some(&self.document_hash) + } + fn document_hash_mut(&mut self) -> Option<&mut String> { + Some(&mut self.document_hash) + } +} + +impl HasDocumentHash for crate::TransferRequest { + fn document_hash(&self) -> Option<&str> { + Some(&self.document_hash) + } + fn document_hash_mut(&mut self) -> Option<&mut String> { + Some(&mut self.document_hash) + } +} + +/// Axum middleware that normalizes hash strings in request bodies and path parameters +pub async fn hash_normalization_middleware( + mut req: Request, + next: Next, +) -> Result { + // First, handle path parameters that might contain hashes (like /verify/:hash) + let uri = req.uri().clone(); + let path = uri.path(); + + // Check if this is an endpoint that has a hash in the path + if let Some(captures) = path + .strip_prefix("/verify/") + .and_then(|p| p.strip_suffix("/history").or_else(|| Some(p))) + { + if !captures.is_empty() && !captures.starts_with("batch") { + // This is /verify/:hash or /verify/:hash/history + // We'll handle the normalization in the handler itself since path params are extracted directly + // Middleware can't easily modify path params, so the existing normalize call in handlers is still used + // But we ensure it's using our shared normalize function + } + } + + // For request bodies, we can deserialize, normalize, then re-serialize + // This works for all JSON requests that have a document_hash field + let body = match req.body_mut().collect().await { + Ok(b) => b.to_bytes(), + Err(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { + error: "Failed to read request body".to_string(), + }), + )) + } + }; + + // Try to deserialize and normalize if it's a request with document_hash + if let Ok(mut verify_req) = serde_json::from_slice::(&body) { + normalize_request_body(&mut verify_req); + // Validate after normalization + if let Err(err) = HashValidator::validate_sha256(&verify_req.document_hash) { + let (status, body) = map_validation_error(err); + return Err((status, Json(body))); + } + // Re-attach the normalized body + *req.body_mut() = serde_json::to_vec(&verify_req).unwrap().into(); + } else if let Ok(mut submit_req) = serde_json::from_slice::(&body) { + normalize_request_body(&mut submit_req); + if let Err(err) = HashValidator::validate_sha256(&submit_req.document_hash) { + let (status, body) = map_validation_error(err); + return Err((status, Json(body))); + } + *req.body_mut() = serde_json::to_vec(&submit_req).unwrap().into(); + } else if let Ok(mut revoke_req) = serde_json::from_slice::(&body) { + normalize_request_body(&mut revoke_req); + if let Err(err) = HashValidator::validate_sha256(&revoke_req.document_hash) { + let (status, body) = map_validation_error(err); + return Err((status, Json(body))); + } + *req.body_mut() = serde_json::to_vec(&revoke_req).unwrap().into(); + } else if let Ok(mut transfer_req) = serde_json::from_slice::(&body) { + normalize_request_body(&mut transfer_req); + if let Err(err) = HashValidator::validate_sha256(&transfer_req.document_hash) { + let (status, body) = map_validation_error(err); + return Err((status, Json(body))); + } + *req.body_mut() = serde_json::to_vec(&transfer_req).unwrap().into(); + } + + // Continue processing the request + Ok(next.run(req).await) +} + +fn map_validation_error(err: ValidationError) -> (StatusCode, ValidationErrorResponse) { + let message = match err { + ValidationError::EmptyHash => "hash must not be empty".to_string(), + ValidationError::WrongLength { expected, actual } => format!( + "hash has wrong length: expected {} characters, got {}", + expected, actual + ), + ValidationError::InvalidCharacter { + position, + character, + } => format!( + "hash contains invalid character '{}' at position {}", + character, position + ), + }; + + ( + StatusCode::BAD_REQUEST, + ValidationErrorResponse { error: message }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_sha256_uppercase() -> &'static str { + "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" + } + + fn sample_sha256_mixed() -> &'static str { + "e3b0c44298FC1c149AFbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + + fn sample_sha256_lowercase() -> &'static str { + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + + #[test] + fn lowercase_input_unchanged() { + let result = normalize(sample_sha256_lowercase()); + assert_eq!(result, sample_sha256_lowercase()); + assert_eq!(result.len(), 64); + } + + #[test] + fn uppercase_input_lowercased() { + let result = normalize(sample_sha256_uppercase()); + assert_eq!(result, sample_sha256_lowercase()); + assert_eq!(result.len(), 64); + } + + #[test] + fn mixed_case_input_lowercased() { + let result = normalize(sample_sha256_mixed()); + assert_eq!(result, sample_sha256_lowercase()); + assert_eq!(result.len(), 64); + } + + #[test] + fn leading_trailing_spaces_stripped() { + let input = format!(" {} ", sample_sha256_lowercase()); + let result = normalize(&input); + assert_eq!(result, sample_sha256_lowercase()); + assert_eq!(result.len(), 64); + } + + #[test] + fn combined_whitespace_and_uppercase() { + let input = format!(" {} ", sample_sha256_uppercase()); + let result = normalize(&input); + assert_eq!(result, sample_sha256_lowercase()); + assert_eq!(result.len(), 64); + } + + #[test] + fn sixtyfour_char_uppercase_normalizes_to_sixtyfour_lowercase() { + let input = sample_sha256_uppercase(); + assert_eq!(input.len(), 64); + let result = normalize(input); + assert_eq!(result.len(), 64); + assert_eq!(result, sample_sha256_lowercase()); + } +} diff --git a/contract/src/module/middleware/mod.rs b/contract/src/module/middleware/mod.rs new file mode 100644 index 0000000..d3c6093 --- /dev/null +++ b/contract/src/module/middleware/mod.rs @@ -0,0 +1 @@ +pub mod hash_normalization; diff --git a/contract/src/module/mod.rs b/contract/src/module/mod.rs index ed702a1..777e5ad 100644 --- a/contract/src/module/mod.rs +++ b/contract/src/module/mod.rs @@ -1,2 +1,3 @@ pub mod cache_warmup; +pub mod middleware; pub mod webhook; diff --git a/contract/tests/history_tests.rs b/contract/tests/history_tests.rs new file mode 100644 index 0000000..7521288 --- /dev/null +++ b/contract/tests/history_tests.rs @@ -0,0 +1,180 @@ +use axum_test::TestServer; +use std::sync::Arc; +use stellar_doc_verifier::cache::{CacheBackend, InMemoryCache}; +use stellar_doc_verifier::metrics::MetricsRegistry; +use stellar_doc_verifier::module::webhook::VerificationWebhookNotifier; +use stellar_doc_verifier::stellar::StellarClient; +use stellar_doc_verifier::{app, AppState, HistoryResponse, TransactionRecord}; + +fn create_test_state() -> AppState { + let stellar = Arc::new(StellarClient::new("https://horizon-testnet.stellar.org")); + let cache = Arc::new(CacheBackend::InMemory(InMemoryCache::new())); + let metrics = Arc::new(MetricsRegistry::new()); + let notifier = Arc::new(VerificationWebhookNotifier::new()); + + AppState { + stellar, + cache, + metrics, + stellar_secret_key: "test_secret".to_string(), + notifier, + } +} + +fn valid_sha256_hash() -> &'static str { + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} + +#[tokio::test] +async fn test_hash_with_no_history_returns_200_empty_array() { + let state = create_test_state(); + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let response = server + .get(&format!("/verify/{}/history", valid_sha256_hash())) + .await; + + response.assert_status_ok(); + let history: HistoryResponse = response.json(); + assert_eq!(history.count, 0); + assert!(history.transactions.is_empty()); + assert_eq!(history.document_hash, valid_sha256_hash()); + assert!(!history.cached); +} + +#[tokio::test] +async fn test_hash_with_one_record_returns_count_one() { + let state = create_test_state(); + let cache_key = format!("history:{}", valid_sha256_hash()); + let transactions = vec![TransactionRecord { + transaction_id: "tx123".to_string(), + timestamp: 1620000000, + verified: true, + }]; + state + .cache + .set(&cache_key, &transactions, 3600) + .await + .unwrap(); + + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let response = server + .get(&format!("/verify/{}/history", valid_sha256_hash())) + .await; + + response.assert_status_ok(); + let history: HistoryResponse = response.json(); + assert_eq!(history.count, 1); + assert_eq!(history.transactions.len(), 1); + assert_eq!(history.transactions[0].transaction_id, "tx123"); + assert!(history.cached); +} + +#[tokio::test] +async fn test_hash_with_multiple_transfers_returns_in_order() { + let state = create_test_state(); + let cache_key = format!("history:{}", valid_sha256_hash()); + let transactions = vec![ + TransactionRecord { + transaction_id: "tx1".to_string(), + timestamp: 1620000000, + verified: true, + }, + TransactionRecord { + transaction_id: "tx2".to_string(), + timestamp: 1630000000, + verified: true, + }, + TransactionRecord { + transaction_id: "tx3".to_string(), + timestamp: 1640000000, + verified: true, + }, + ]; + state + .cache + .set(&cache_key, &transactions, 3600) + .await + .unwrap(); + + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let response = server + .get(&format!("/verify/{}/history", valid_sha256_hash())) + .await; + + response.assert_status_ok(); + let history: HistoryResponse = response.json(); + assert_eq!(history.count, 3); + assert_eq!(history.transactions.len(), 3); + assert_eq!(history.transactions[0].transaction_id, "tx1"); + assert_eq!(history.transactions[1].transaction_id, "tx2"); + assert_eq!(history.transactions[2].transaction_id, "tx3"); + assert!(history.cached); +} + +#[tokio::test] +async fn test_cache_hit_returns_cached_true() { + let state = create_test_state(); + let cache_key = format!("history:{}", valid_sha256_hash()); + let transactions = vec![TransactionRecord { + transaction_id: "tx_cache".to_string(), + timestamp: 1620000000, + verified: true, + }]; + state + .cache + .set(&cache_key, &transactions, 3600) + .await + .unwrap(); + + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let response = server + .get(&format!("/verify/{}/history", valid_sha256_hash())) + .await; + + response.assert_status_ok(); + let history: HistoryResponse = response.json(); + assert!(history.cached); + assert_eq!(history.count, 1); +} + +#[tokio::test] +async fn test_invalid_hash_format_returns_400() { + let state = create_test_state(); + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let invalid_hash = "invalid_hash"; // Not 64 hex chars + let response = server + .get(&format!("/verify/{}/history", invalid_hash)) + .await; + + response.assert_status_bad_request(); + let error: serde_json::Value = response.json(); + assert!(error.get("error").is_some()); + assert!(error["error"].as_str().unwrap().contains("wrong length")); +} + +#[tokio::test] +async fn test_empty_hash_string_returns_400() { + let state = create_test_state(); + let app = app(state); + let server = TestServer::new(app).unwrap(); + + let response = server.get("/verify//history").await; + + response.assert_status_bad_request(); + let error: serde_json::Value = response.json(); + assert!(error.get("error").is_some()); + assert!(error["error"] + .as_str() + .unwrap() + .contains("must not be empty")); +}