diff --git a/contract/Cargo.toml b/contract/Cargo.toml index 5246abf..ec1d508 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" axum = "0.7" tokio = { version = "1.35", features = ["full"] } tower = "0.4" -tower-http = { version = "0.5", features = ["trace", "cors"] } +tower-http = { version = "0.5", features = ["trace", "cors", "request-id"] } url = "2" futures = "0.3" @@ -36,7 +36,7 @@ prometheus = "0.13" # Logging tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } # Error handling thiserror = "1.0" diff --git a/contract/src/cache.rs b/contract/src/cache.rs index b535809..bc110e8 100644 --- a/contract/src/cache.rs +++ b/contract/src/cache.rs @@ -6,12 +6,14 @@ use std::sync::Arc; use tokio::sync::RwLock; use tracing::warn; +#[derive(Clone)] pub enum CacheBackend { Redis(RedisCache), InMemory(InMemoryCache), } impl CacheBackend { + #[tracing::instrument(name = "cache.check_connection", skip(self))] pub async fn check_connection(&self) -> bool { match self { Self::Redis(c) => c.check_connection().await, @@ -19,8 +21,7 @@ impl CacheBackend { } } - /// Closes the underlying connection cleanly. Called during graceful - /// shutdown, after in-flight requests have finished draining. + #[tracing::instrument(name = "cache.close", skip(self))] pub async fn close(&self) { match self { Self::Redis(c) => c.close().await, @@ -28,6 +29,7 @@ impl CacheBackend { } } + #[tracing::instrument(name = "cache.get_raw", skip(self), fields(key = %key))] pub async fn get_raw(&self, key: &str) -> Result> { match self { Self::Redis(c) => c.get_raw(key).await, @@ -35,6 +37,7 @@ impl CacheBackend { } } + #[tracing::instrument(name = "cache.set_raw", skip(self, value), fields(key = %key, ttl = ttl))] pub async fn set_raw(&self, key: &str, value: &str, ttl: u64) -> Result<()> { match self { Self::Redis(c) => c.set_raw(key, value, ttl).await, @@ -42,6 +45,7 @@ impl CacheBackend { } } + #[tracing::instrument(name = "cache.get", skip(self), fields(key = %key))] pub async fn get(&self, key: &str) -> Result> where T: for<'de> Deserialize<'de>, @@ -52,6 +56,7 @@ impl CacheBackend { } } + #[tracing::instrument(name = "cache.set", skip(self, value), fields(key = %key, ttl = ttl))] pub async fn set(&self, key: &str, value: &T, ttl: u64) -> Result<()> where T: Serialize, @@ -60,6 +65,7 @@ impl CacheBackend { self.set_raw(key, &serialized, ttl).await } + #[tracing::instrument(name = "cache.delete", skip(self), fields(key = %key))] pub async fn delete(&self, key: &str) -> Result<()> { match self { Self::Redis(c) => c.delete(key).await, @@ -68,6 +74,7 @@ impl CacheBackend { } } +#[derive(Clone)] pub struct RedisCache { connection: ConnectionManager, } @@ -114,6 +121,7 @@ impl RedisCache { } } +#[derive(Clone)] pub struct InMemoryCache { store: Arc>>, } @@ -182,7 +190,10 @@ mod tests { async fn test_overwrite_value() { let cache = in_memory_backend(); cache.set("key1", &"first".to_string(), 3600).await.unwrap(); - cache.set("key1", &"second".to_string(), 3600).await.unwrap(); + cache + .set("key1", &"second".to_string(), 3600) + .await + .unwrap(); let result: Option = cache.get("key1").await.unwrap(); assert_eq!(result, Some("second".to_string())); } @@ -264,7 +275,10 @@ mod tests { #[tokio::test] async fn test_cache_backend_enum_dispatch() { let cache = in_memory_backend(); - cache.set("enum_key", &"enum_value".to_string(), 3600).await.unwrap(); + cache + .set("enum_key", &"enum_value".to_string(), 3600) + .await + .unwrap(); let result: Option = cache.get("enum_key").await.unwrap(); assert_eq!(result, Some("enum_value".to_string())); } diff --git a/contract/src/config.rs b/contract/src/config.rs index f40ac15..04bf319 100644 --- a/contract/src/config.rs +++ b/contract/src/config.rs @@ -3,6 +3,23 @@ use std::env; use thiserror::Error; use url::Url; +/// Deployment environment. Controls log output format: JSON in production, +/// human-readable in development. Unrecognized values default to development. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Environment { + Development, + Production, +} + +impl Environment { + fn from_env_str(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "production" | "prod" => Self::Production, + _ => Self::Development, + } + } +} + #[derive(Debug, Clone)] pub struct AppConfig { pub port: u16, @@ -13,6 +30,7 @@ pub struct AppConfig { pub rate_limit_burst: u32, pub stellar_max_retries: u32, pub log_level: String, + pub environment: Environment, pub webhook_urls: Vec, pub webhook_secret: Option, pub cache_verification_ttl: u64, @@ -40,6 +58,7 @@ impl AppConfig { get_env_or_default("STELLAR_HORIZON_URL", "https://horizon-testnet.stellar.org"); let redis_url = get_env_or_default("REDIS_URL", "redis://127.0.0.1:6379"); let log_level = get_env_or_default("LOG_LEVEL", "info"); + let environment = Environment::from_env_str(&get_env_or_default("APP_ENV", "development")); let webhook_urls_raw = get_env_or_default("WEBHOOK_URLS", ""); let stellar_secret_key = match env::var("STELLAR_SECRET_KEY") { @@ -177,6 +196,7 @@ impl AppConfig { rate_limit_burst, stellar_max_retries, log_level, + environment, webhook_urls, webhook_secret, cache_verification_ttl, @@ -202,6 +222,7 @@ mod tests { "RATE_LIMIT_BURST", "STELLAR_MAX_RETRIES", "LOG_LEVEL", + "APP_ENV", "WEBHOOK_URLS", "WEBHOOK_SECRET", "CACHE_VERIFICATION_TTL", diff --git a/contract/src/handlers/health.rs b/contract/src/handlers/health.rs index cf5e29f..1ee24c5 100644 --- a/contract/src/handlers/health.rs +++ b/contract/src/handlers/health.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::State, - response::IntoResponse, - Json, -}; +use axum::{extract::State, response::IntoResponse, Json}; use crate::types::{AppState, HealthResponse}; diff --git a/contract/src/handlers/submit.rs b/contract/src/handlers/submit.rs index 41fd125..e27ddfc 100644 --- a/contract/src/handlers/submit.rs +++ b/contract/src/handlers/submit.rs @@ -7,8 +7,7 @@ use axum::{ use tracing::{info, warn}; use crate::hash_validator::HashValidator; -use crate::stellar::derive_account_id; -use crate::types::{AppState, SubmitRequest, SubmitResponse}; +use crate::types::{map_validation_error, AppState, SubmitRequest, SubmitResponse}; /// POST /submit — anchor a document hash to Stellar using a ManageData operation. /// @@ -22,7 +21,7 @@ pub async fn submit_document( ) -> Response { let normalized_hash = HashValidator::normalize(&req.document_hash); if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { - let (status, body) = super::verify::map_validation_error_inline(err); + let (status, body) = map_validation_error(err); return (status, Json(body)).into_response(); } diff --git a/contract/src/handlers/transfer.rs b/contract/src/handlers/transfer.rs index f952b60..cbb1ec1 100644 --- a/contract/src/handlers/transfer.rs +++ b/contract/src/handlers/transfer.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, - response::{IntoResponse, Response}, + response::IntoResponse, Json, }; use chrono::{NaiveDate, Utc}; diff --git a/contract/src/handlers/verify.rs b/contract/src/handlers/verify.rs index 928d5a8..0bad739 100644 --- a/contract/src/handlers/verify.rs +++ b/contract/src/handlers/verify.rs @@ -11,7 +11,7 @@ use crate::hash_validator::{HashValidator, ValidationError as HashValidationErro use crate::stellar::derive_account_id; use crate::types::{ map_validation_error, AppState, BatchVerifyItem, BatchVerifyRequest, BatchVerifyResponse, - HistoryResponse, VerifyRequest, VerifyResponse, ValidationErrorResponse, + HistoryResponse, ValidationErrorResponse, VerifyRequest, VerifyResponse, }; // Verify document by POST diff --git a/contract/src/lib.rs b/contract/src/lib.rs index 7403444..2892605 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -9,6 +9,1207 @@ pub mod routes; pub mod stellar; pub mod types; -pub use handlers::{health, revoke, submit, transfer, verify}; -pub use routes::app; -pub use types::*; +use axum::{ + extract::{Path, State}, + http::{HeaderName, Request, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use chrono::{NaiveDate, Utc}; +use futures::future::join_all; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; +use tower::ServiceBuilder; +use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; +use tower_http::trace::TraceLayer; +use tracing::{info, warn}; + +use cache::CacheBackend; +use hash_validator::{HashValidator, ValidationError as HashValidationError}; +use metrics::MetricsRegistry; +use stellar::{derive_account_id, StellarClient, TransactionRecord}; + +/// Header used to correlate a single logical operation across the NestJS +/// backend and this verifier service (see BE-136). Must match the header +/// name used by the backend. +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +// Application state +#[derive(Clone)] +pub struct AppState { + pub stellar: Arc, + pub cache: Arc, + pub metrics: Arc, + pub stellar_secret_key: String, +} + +// Request/Response types +#[derive(Debug, Deserialize)] +pub struct VerifyRequest { + pub document_hash: String, + pub transaction_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VerifyResponse { + pub verified: bool, + pub transaction_id: Option, + pub timestamp: Option, + pub cached: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, +} + +/// Request type for submitting a document hash to Stellar blockchain +#[derive(Debug, Deserialize)] +pub struct SubmitRequest { + pub document_hash: String, + pub document_id: String, + pub submitter: String, +} + +/// Response type for document hash submission +#[derive(Debug, Serialize, Deserialize)] +pub struct SubmitResponse { + pub success: bool, + pub transaction_id: Option, + pub anchored_at: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RevokeRequest { + pub document_hash: String, + pub reason: String, + pub revoked_by: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RevokeResponse { + pub transaction_id: String, + pub revoked_at: i64, + pub revoked: bool, +} + +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub status: String, + pub stellar_connected: bool, + pub redis_connected: bool, +} + +/// Response type for document verification history +#[derive(Debug, Serialize)] +pub struct HistoryResponse { + pub document_hash: String, + pub transactions: Vec, + pub count: usize, + pub cached: bool, +} + +#[derive(Debug, Serialize)] +pub struct ValidationErrorResponse { + pub error: String, +} + +#[derive(Debug, Deserialize)] +pub struct BatchVerifyRequest { + pub hashes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BatchVerifyResponse { + pub results: Vec, + pub total: usize, + pub verified_count: usize, + pub failed_count: usize, +} + +#[derive(Debug, Serialize)] +pub struct BatchVerifyItem { + pub hash: String, + pub verified: bool, + pub transaction_id: Option, + pub timestamp: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TransferRequest { + pub document_hash: String, + pub from_owner: String, + pub to_owner: String, + pub transfer_date: String, + pub transfer_reference: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TransferRecord { + pub document_hash: String, + pub from_owner: String, + pub to_owner: String, + pub transfer_date: String, + pub transfer_reference: String, + pub transfer_hash: String, + pub memo: String, + pub anchored_at: String, +} + +#[derive(Debug, Serialize)] +pub struct TransferResponse { + pub transfer_hash: String, + pub memo: String, +} + +fn map_validation_error(err: HashValidationError) -> (StatusCode, ValidationErrorResponse) { + let message = match err { + HashValidationError::EmptyHash => "hash must not be empty".to_string(), + HashValidationError::WrongLength { expected, actual } => format!( + "hash has wrong length: expected {} characters, got {}", + expected, actual + ), + HashValidationError::InvalidCharacter { + position, + character, + } => format!( + "hash contains invalid character '{}' at position {}", + character, position + ), + }; + + ( + StatusCode::BAD_REQUEST, + ValidationErrorResponse { error: message }, + ) +} + +pub fn app(state: AppState) -> Router { + let request_id_header = HeaderName::from_static(REQUEST_ID_HEADER); + let span_header = request_id_header.clone(); + + Router::new() + .route("/health", get(health_check)) + .route("/metrics", get(metrics_handler)) + .route("/verify", post(verify_document)) + .route("/verify/batch", post(batch_verify_documents)) + .route("/verify/:hash", get(verify_document_by_hash)) + .route("/verify/:hash/history", get(verify_document_history)) + .route("/submit", post(submit_document)) + .route("/revoke", post(revoke_document)) + .route("/transfer", post(record_transfer)) + .layer( + ServiceBuilder::new() + // Honour an inbound X-Request-Id (propagated from the NestJS + // backend, per BE-136); generate one only if it's absent. + .layer(SetRequestIdLayer::new( + request_id_header.clone(), + MakeRequestUuid, + )) + .layer( + TraceLayer::new_for_http().make_span_with(move |request: &Request<_>| { + let request_id = request + .headers() + .get(&span_header) + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + tracing::info_span!( + "http_request", + method = %request.method(), + path = %request.uri().path(), + request_id = %request_id, + ) + }), + ) + // Echo the request id back on the response so callers (and + // the backend) can confirm which id was used for this op. + .layer(PropagateRequestIdLayer::new(request_id_header)), + ) + .with_state(state) +} + +// Health check endpoint +pub async fn health_check(State(state): State) -> impl IntoResponse { + let stellar_ok = state.stellar.check_connection().await; + let redis_ok = state.cache.check_connection().await; + + let status = if stellar_ok && redis_ok { + "healthy" + } else { + "degraded" + }; + + Json(HealthResponse { + status: status.to_string(), + stellar_connected: stellar_ok, + redis_connected: redis_ok, + }) +} + +// Metrics endpoint +pub async fn metrics_handler(State(state): State) -> impl IntoResponse { + state.metrics.render() +} + +/// Compute deterministic transfer hash from core fields. +/// +/// SHA-256(document_hash + from_owner + to_owner + transfer_date) +pub fn compute_transfer_hash(req: &TransferRequest) -> String { + let mut hasher = Sha256::new(); + hasher.update(req.document_hash.as_bytes()); + hasher.update(req.from_owner.as_bytes()); + hasher.update(req.to_owner.as_bytes()); + hasher.update(req.transfer_date.as_bytes()); + let digest = hasher.finalize(); + hex::encode(digest) +} + +/// Validate that the provided date is a valid ISO 8601 calendar date (YYYY-MM-DD). +fn is_valid_iso8601_date(date: &str) -> bool { + NaiveDate::parse_from_str(date, "%Y-%m-%d").is_ok() +} + +/// Build a Stellar memo string for a transfer hash, respecting the 28-byte +/// text memo limit and using the required TRANSFER: prefix. +fn build_transfer_memo(transfer_hash: &str) -> String { + const PREFIX: &str = "TRANSFER:"; + const MAX_MEMO_LEN: usize = 28; + + let remaining = MAX_MEMO_LEN.saturating_sub(PREFIX.len()); + let truncated = if transfer_hash.len() > remaining { + &transfer_hash[..remaining] + } else { + transfer_hash + }; + + format!("{}{}", PREFIX, truncated) +} + +/// POST /transfer — anchor an ownership transfer on Stellar and persist history in Redis. +pub async fn record_transfer( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + if !is_valid_iso8601_date(&req.transfer_date) { + return Err(StatusCode::BAD_REQUEST); + } + + let transfer_hash = compute_transfer_hash(&req); + let memo = build_transfer_memo(&transfer_hash); + + let anchor_account_id = derive_account_id(&state.stellar_secret_key).map_err(|e| { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if let Err(e) = state + .stellar + .anchor_transfer( + &transfer_hash, + &anchor_account_id, + &state.stellar_secret_key, + ) + .await + { + warn!("Failed to anchor transfer on Stellar: {}", e); + state.metrics.increment_error_count(); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let record = TransferRecord { + document_hash: req.document_hash.clone(), + from_owner: req.from_owner.clone(), + to_owner: req.to_owner.clone(), + transfer_date: req.transfer_date.clone(), + transfer_reference: req.transfer_reference.clone(), + transfer_hash: transfer_hash.clone(), + memo: memo.clone(), + anchored_at: Utc::now().to_rfc3339(), + }; + + let key = format!("transfer:{}", record.document_hash); + + let mut history: Vec = match state.cache.get(&key).await { + Ok(Some(existing)) => existing, + Ok(None) => Vec::new(), + Err(e) => { + warn!("Failed to read transfer history from cache: {}", e); + state.metrics.increment_error_count(); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + history.push(record); + + // Set a long but finite TTL (10 years) to keep an auditable history + const TEN_YEARS_SECONDS: u64 = 60 * 60 * 24 * 365 * 10; + if let Err(e) = state.cache.set(&key, &history, TEN_YEARS_SECONDS).await { + warn!("Failed to persist transfer history: {}", e); + state.metrics.increment_error_count(); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + Ok(Json(TransferResponse { + transfer_hash, + memo, + })) +} + +/// GET /transfer/:document_hash — retrieve transfer history for a document. +pub async fn get_transfer_history( + State(state): State, + Path(document_hash): Path, +) -> Result>, StatusCode> { + let key = format!("transfer:{}", document_hash); + match state.cache.get::>(&key).await { + Ok(Some(history)) => Ok(Json(history)), + Ok(None) => Ok(Json(Vec::new())), + Err(e) => { + warn!("Failed to fetch transfer history from cache: {}", e); + state.metrics.increment_error_count(); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +// Verify document by POST +pub async fn verify_document( + State(state): State, + Json(req): Json, +) -> Response { + let normalized_hash = HashValidator::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(); + } + + info!("Verifying document hash: {}", normalized_hash); + state.metrics.increment_request_count(); + + // Check cache first + if let Ok(Some(cached)) = state.cache.get::(&normalized_hash).await { + info!("Cache hit for hash: {}", normalized_hash); + state.metrics.increment_cache_hits(); + return Json(cached).into_response(); + } + + state.metrics.increment_cache_misses(); + + let anchor_account_id = match derive_account_id(&state.stellar_secret_key) { + Ok(id) => id, + Err(e) => { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + // Query Stellar blockchain + let result = match state + .stellar + .verify_hash(&normalized_hash, &anchor_account_id) + .await + { + Ok(verification) => verification, + Err(e) => { + warn!("Stellar query failed: {}", e); + state.metrics.increment_error_count(); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let response = VerifyResponse { + verified: result.anchored, + transaction_id: result.transaction_id, + timestamp: result.timestamp, + cached: false, + revoked: None, + revoked_at: None, + }; + + Json(response).into_response() +} + +// Verify document by GET with hash in path +pub async fn verify_document_by_hash( + State(state): State, + Path(hash): Path, +) -> Response { + let req = VerifyRequest { + document_hash: hash, + transaction_id: None, + }; + verify_document(State(state), Json(req)).await +} + +// Verify document history by hash +pub async fn verify_document_history( + State(state): State, + Path(hash): Path, +) -> Response { + let normalized_hash = HashValidator::normalize(&hash); + if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { + let (status, body) = map_validation_error(err); + return (status, Json(body)).into_response(); + } + + let cache_key = format!("history:{}", normalized_hash); + let transactions: Vec = match state.cache.get(&cache_key).await { + Ok(Some(records)) => records, + Ok(None) => Vec::new(), + Err(e) => { + warn!("Failed to fetch history from cache: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let count = transactions.len(); + let cached = !transactions.is_empty(); + + Json(HistoryResponse { + document_hash: normalized_hash, + transactions, + count, + cached, + }) + .into_response() +} + +// Batch verify documents +pub async fn batch_verify_documents( + State(state): State, + Json(req): Json, +) -> Response { + // Validate batch size + if req.hashes.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { + error: "hashes array cannot be empty".to_string(), + }), + ) + .into_response(); + } + + if req.hashes.len() > 50 { + return ( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { + error: "batch size exceeds maximum of 50 hashes".to_string(), + }), + ) + .into_response(); + } + + info!("Batch verifying {} document hashes", req.hashes.len()); + state.metrics.increment_request_count(); + + // Process all hashes concurrently + let verification_futures: Vec<_> = req + .hashes + .iter() + .map(|hash| { + let state = state.clone(); + let hash = hash.clone(); + + async move { verify_single_hash(&state, hash).await } + }) + .collect(); + + let results = join_all(verification_futures).await; + + let verified_count = results.iter().filter(|item| item.verified).count(); + let failed_count = results.len() - verified_count; + + let response = BatchVerifyResponse { + results, + total: req.hashes.len(), + verified_count, + failed_count, + }; + + Json(response).into_response() +} + +// Helper function to verify a single hash +async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem { + let normalized_hash = HashValidator::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(), + HashValidationError::WrongLength { expected, actual } => format!( + "hash has wrong length: expected {} characters, got {}", + expected, actual + ), + HashValidationError::InvalidCharacter { + position, + character, + } => format!( + "hash contains invalid character '{}' at position {}", + character, position + ), + }; + + return BatchVerifyItem { + hash, + verified: false, + transaction_id: None, + timestamp: None, + error: Some(error_msg), + }; + } + + // Check cache first + if let Ok(Some(cached)) = state.cache.get::(&normalized_hash).await { + info!("Cache hit for hash: {}", normalized_hash); + state.metrics.increment_cache_hits(); + + return BatchVerifyItem { + hash, + verified: cached.verified, + transaction_id: cached.transaction_id, + timestamp: cached.timestamp, + error: None, + }; + } + + state.metrics.increment_cache_misses(); + + let anchor_account_id = match derive_account_id(&state.stellar_secret_key) { + Ok(id) => id, + Err(e) => { + warn!("Failed to derive anchor account id: {}", e); + state.metrics.increment_error_count(); + + return BatchVerifyItem { + hash, + verified: false, + transaction_id: None, + timestamp: None, + error: Some(format!("failed to derive anchor account id: {}", e)), + }; + } + }; + + // Query Stellar blockchain + let result = match state + .stellar + .verify_hash(&normalized_hash, &anchor_account_id) + .await + { + Ok(verification) => verification, + Err(e) => { + warn!("Stellar query failed for hash {}: {}", normalized_hash, e); + state.metrics.increment_error_count(); + + return BatchVerifyItem { + hash, + verified: false, + transaction_id: None, + timestamp: None, + error: Some(format!("stellar query failed: {}", e)), + }; + } + }; + + // Cache the result + let cache_response = VerifyResponse { + verified: result.anchored, + transaction_id: result.transaction_id.clone(), + timestamp: result.timestamp, + cached: false, + revoked: None, + revoked_at: None, + }; + + if let Err(e) = state + .cache + .set(&normalized_hash, &cache_response, 3600) + .await + { + warn!("Failed to cache result for hash {}: {}", normalized_hash, e); + } + + BatchVerifyItem { + hash, + verified: result.anchored, + transaction_id: result.transaction_id, + timestamp: result.timestamp, + error: None, + } +} + +/// POST /submit — anchor a document hash to Stellar using a ManageData operation. +/// +/// Request body: `{ document_hash, document_id, submitter }` +/// +/// On success returns `{ success: true, transaction_id, anchored_at }`. +/// Duplicate submissions return the cached result with `200 OK` (idempotent). +pub async fn submit_document( + State(state): State, + Json(req): Json, +) -> Response { + let normalized_hash = HashValidator::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(); + } + + let cache_key = format!("stellar:verify:{}", normalized_hash); + + // Idempotency check — return cached anchor result if it exists. + if let Ok(Some(cached)) = state.cache.get::(&cache_key).await { + info!( + "Cache hit for submit: returning existing anchor for {}", + normalized_hash + ); + return Json(cached).into_response(); + } + + info!( + "Anchoring document hash {} submitted by {}", + normalized_hash, req.submitter + ); + state.metrics.increment_request_count(); + + match state + .stellar + .anchor_hash(&normalized_hash, &req.submitter, &state.stellar_secret_key) + .await + { + Ok(result) => { + let response = SubmitResponse { + success: true, + transaction_id: Some(result.tx_hash.clone()), + anchored_at: Some(result.anchored_at), + error: None, + }; + + // Cache the result so duplicate submissions get a fast 200. + const ANCHOR_CACHE_TTL: u64 = 60 * 60 * 24 * 365; // 1 year + if let Err(e) = state + .cache + .set(&cache_key, &response, ANCHOR_CACHE_TTL) + .await + { + warn!( + "Failed to cache anchor result for {}: {}", + normalized_hash, e + ); + } + + info!( + "Document hash {} anchored in ledger {} (tx: {})", + normalized_hash, result.ledger, result.tx_hash + ); + Json(response).into_response() + } + Err(e) => { + warn!("Stellar anchor failed for {}: {}", normalized_hash, e); + state.metrics.increment_error_count(); + ( + StatusCode::BAD_GATEWAY, + Json(SubmitResponse { + success: false, + transaction_id: None, + anchored_at: None, + error: Some(e.to_string()), + }), + ) + .into_response() + } + } +} + +/// POST /revoke — record a document revocation on Stellar. +/// +/// Writes a `ManageData` entry with key `"revoked_" + hash[:56]` and +/// value `{ revokedAt, reason }` as bytes. The original `doc_` entry is +/// preserved so audit history remains intact. +/// +/// After a successful on-chain revocation the Redis cache entry for +/// `stellar:verify:{hash}` is updated so that subsequent `GET /verify/:hash` +/// calls return `{ verified: true, revoked: true, revokedAt }`. +/// +/// Returns `404` if the hash has no prior anchor record. +pub async fn revoke_document( + State(state): State, + Json(req): Json, +) -> Response { + let normalized_hash = HashValidator::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(); + } + + let anchor_key = format!("stellar:verify:{}", normalized_hash); + + // Ensure the document was previously anchored before revoking. + let existing: Option = state + .cache + .get::(&anchor_key) + .await + .unwrap_or(None); + + if existing.is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ValidationErrorResponse { + error: "document hash has no prior anchor record; cannot revoke".to_string(), + }), + ) + .into_response(); + } + + info!( + "Revoking document hash {} (revoked_by: {})", + normalized_hash, req.revoked_by + ); + state.metrics.increment_request_count(); + + let revoked_at = Utc::now().timestamp(); + + // Build the revocation payload stored as ManageData value. + let revocation_value = serde_json::json!({ + "revokedAt": Utc::now().to_rfc3339(), + "reason": req.reason, + "revokedBy": req.revoked_by, + }) + .to_string(); + + // Use stellar.rs anchor_hash logic directly — we build a new ManageData tx + // with the revocation key. + match state + .stellar + .anchor_revocation( + &normalized_hash, + &revocation_value, + &req.revoked_by, + &state.stellar_secret_key, + ) + .await + { + Ok(result) => { + // Update the cached verify entry to reflect revocation. + let updated_verify = VerifyResponse { + verified: true, + transaction_id: existing.and_then(|r| r.transaction_id), + timestamp: Some(revoked_at), + cached: false, + revoked: Some(true), + revoked_at: Some(revoked_at), + }; + const REVOKE_CACHE_TTL: u64 = 60 * 60 * 24 * 365; + if let Err(e) = state + .cache + .set(&anchor_key, &updated_verify, REVOKE_CACHE_TTL) + .await + { + warn!("Failed to update cache after revocation: {}", e); + } + + info!( + "Document {} revoked in ledger {} (tx: {})", + normalized_hash, result.ledger, result.tx_hash + ); + + Json(RevokeResponse { + transaction_id: result.tx_hash, + revoked_at, + revoked: true, + }) + .into_response() + } + Err(e) => { + warn!("Revocation failed for {}: {}", normalized_hash, e); + state.metrics.increment_error_count(); + ( + StatusCode::BAD_GATEWAY, + Json(ValidationErrorResponse { + error: format!("Stellar revocation failed: {}", e), + }), + ) + .into_response() + } + } +} + +pub async fn transfer_document(Json(req): Json) -> impl IntoResponse { + let normalized_hash = HashValidator::normalize(&req.document_hash); + if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { + let (status, body) = map_validation_error(err); + return (status, Json(body)); + } + + // Basic date validation: expect YYYY-MM-DD + if chrono::NaiveDate::parse_from_str(&req.transfer_date, "%Y-%m-%d").is_err() { + return ( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { + error: "invalid date format, expected YYYY-MM-DD".to_string(), + }), + ); + } + + // Endpoint behavior not yet implemented; for now respond with BAD_REQUEST. + ( + StatusCode::BAD_REQUEST, + Json(ValidationErrorResponse { + error: "transfer endpoint not yet implemented".to_string(), + }), + ) +} + +/// Calculates Levenshtein distance between two strings +pub fn levenshtein_distance(s1: &str, s2: &str) -> usize { + let len1 = s1.len(); + let len2 = s2.len(); + let mut matrix = vec![vec![0; len2 + 1]; len1 + 1]; + + for (i, row) in matrix.iter_mut().enumerate() { + row[0] = i; + } + for (j, cell) in matrix[0].iter_mut().enumerate() { + *cell = j; + } + + for (i, c1) in s1.chars().enumerate() { + for (j, c2) in s2.chars().enumerate() { + let cost = if c1 == c2 { 0 } else { 1 }; + matrix[i + 1][j + 1] = std::cmp::min( + std::cmp::min(matrix[i][j + 1] + 1, matrix[i + 1][j] + 1), + matrix[i][j] + cost, + ); + } + } + + matrix[len1][len2] +} + +/// Normalizes Levenshtein distance to similarity score (0-1) +pub fn levenshtein_similarity(s1: &str, s2: &str) -> f64 { + let distance = levenshtein_distance(s1, s2) as f64; + let max_len = s1.len().max(s2.len()) as f64; + if max_len == 0.0 { + return 1.0; + } + 1.0 - (distance / max_len) +} + +/// Tokenizes text and calculates term frequencies +fn tokenize(text: &str) -> HashMap { + let mut frequencies = HashMap::new(); + let lowercased = text.to_lowercase(); + let words: Vec<&str> = lowercased + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| !w.is_empty()) + .collect(); + + for word in words { + *frequencies.entry(word.to_string()).or_insert(0) += 1; + } + frequencies +} + +/// Calculates cosine similarity between two documents +pub fn cosine_similarity(doc1: &str, doc2: &str) -> f64 { + let freq1 = tokenize(doc1); + let freq2 = tokenize(doc2); + + if freq1.is_empty() || freq2.is_empty() { + return 0.0; + } + + let mut dot_product = 0.0; + for (word, count1) in &freq1 { + if let Some(&count2) = freq2.get(word) { + dot_product += (*count1 as f64) * (count2 as f64); + } + } + + let magnitude1: f64 = freq1 + .values() + .map(|c| (*c as f64).powi(2)) + .sum::() + .sqrt(); + let magnitude2: f64 = freq2 + .values() + .map(|c| (*c as f64).powi(2)) + .sum::() + .sqrt(); + + if magnitude1 == 0.0 || magnitude2 == 0.0 { + return 0.0; + } + + dot_product / (magnitude1 * magnitude2) +} + +/// Document similarity result +#[derive(Debug, Clone)] +pub struct SimilarityResult { + pub doc1: String, + pub doc2: String, + pub cosine: f64, + pub levenshtein: f64, + pub combined: f64, +} + +/// Compares two documents and returns similarity scores +pub fn compare_documents(doc1: &str, doc2: &str) -> SimilarityResult { + let cosine = cosine_similarity(doc1, doc2); + let levenshtein = levenshtein_similarity(doc1, doc2); + let combined = (cosine + levenshtein) / 2.0; + + SimilarityResult { + doc1: doc1.to_string(), + doc2: doc2.to_string(), + cosine, + levenshtein, + combined, + } +} + +/// Batch comparison of documents against a reference +pub fn batch_compare(reference: &str, documents: &[&str]) -> Vec { + documents + .iter() + .map(|doc| compare_documents(reference, doc)) + .collect() +} + +/// Finds duplicate documents above threshold +pub fn find_duplicates(documents: &[&str], threshold: f64) -> Vec<(usize, usize, f64)> { + let mut duplicates = Vec::new(); + for i in 0..documents.len() { + for j in (i + 1)..documents.len() { + let similarity = compare_documents(documents[i], documents[j]).combined; + if similarity >= threshold { + duplicates.push((i, j, similarity)); + } + } + } + duplicates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap()); + duplicates +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_levenshtein_identical() { + assert_eq!(levenshtein_distance("hello", "hello"), 0); + } + + #[test] + fn test_levenshtein_different() { + assert_eq!(levenshtein_distance("kitten", "sitting"), 3); + } + + #[test] + fn test_levenshtein_similarity() { + let sim = levenshtein_similarity("hello", "hello"); + assert!(sim >= 0.99); + } + + #[test] + fn test_cosine_identical() { + let sim = cosine_similarity("hello world", "hello world"); + assert!((sim - 1.0).abs() < 0.001); + } + + #[test] + fn test_cosine_different() { + let sim = cosine_similarity("hello world", "goodbye world"); + assert!(sim > 0.0 && sim < 1.0); + } + + #[test] + fn test_compare_documents() { + let result = compare_documents("the quick brown fox", "the quick brown fox"); + assert!(result.combined >= 0.99); + } + + #[test] + fn test_batch_compare() { + let ref_doc = "hello world"; + let docs = vec!["hello world", "hello there", "goodbye"]; + let results = batch_compare(ref_doc, &docs); + assert_eq!(results.len(), 3); + assert!(results[0].combined > results[2].combined); + } + + #[test] + fn test_find_duplicates() { + let docs = vec![ + "the quick brown fox jumps", + "the quick brown fox jumps", + "completely different text", + ]; + let duplicates = find_duplicates(&docs, 0.8); + assert!(!duplicates.is_empty()); + assert_eq!(duplicates[0].0, 0); + assert_eq!(duplicates[0].1, 1); + } + + #[test] + fn test_transfer_hash_deterministic() { + let req = TransferRequest { + document_hash: "doc123".to_string(), + from_owner: "Alice".to_string(), + to_owner: "Bob".to_string(), + transfer_date: "2025-01-01".to_string(), + transfer_reference: "REF-1".to_string(), + }; + + let h1 = compute_transfer_hash(&req); + let h2 = compute_transfer_hash(&req); + + assert_eq!(h1, h2); + } + + #[test] + fn test_transfer_hash_changes_with_input() { + let base = TransferRequest { + document_hash: "doc123".to_string(), + from_owner: "Alice".to_string(), + to_owner: "Bob".to_string(), + transfer_date: "2025-01-01".to_string(), + transfer_reference: "REF-1".to_string(), + }; + + let mut modified = base.clone(); + modified.to_owner = "Charlie".to_string(); + + let h1 = compute_transfer_hash(&base); + let h2 = compute_transfer_hash(&modified); + + assert_ne!(h1, h2); + } + + #[test] + fn test_iso8601_date_validation() { + assert!(is_valid_iso8601_date("2025-12-31")); + assert!(!is_valid_iso8601_date("2025-13-01")); + assert!(!is_valid_iso8601_date("not-a-date")); + } + + #[test] + fn test_batch_verify_request_validation() { + // Test empty batch + let empty_request = BatchVerifyRequest { hashes: vec![] }; + assert!(empty_request.hashes.is_empty()); + + // Test valid batch size + let mut valid_hashes = Vec::new(); + for i in 0..10 { + valid_hashes.push(format!("{:064x}", i)); + } + let valid_request = BatchVerifyRequest { + hashes: valid_hashes, + }; + assert!(!valid_request.hashes.is_empty()); + assert!(valid_request.hashes.len() <= 50); + + // Test batch size exceeding limit + let mut too_many_hashes = Vec::new(); + for i in 0..51 { + too_many_hashes.push(format!("{:064x}", i)); + } + let oversized_request = BatchVerifyRequest { + hashes: too_many_hashes, + }; + assert!(oversized_request.hashes.len() > 50); + } + + #[test] + fn test_batch_verify_response_structure() { + let results = vec![ + BatchVerifyItem { + hash: "hash1".to_string(), + verified: true, + transaction_id: Some("tx1".to_string()), + timestamp: Some(1234567890), + error: None, + }, + BatchVerifyItem { + hash: "hash2".to_string(), + verified: false, + transaction_id: None, + timestamp: None, + error: Some("verification failed".to_string()), + }, + ]; + + let response = BatchVerifyResponse { + total: results.len(), + verified_count: 1, + failed_count: 1, + results, + }; + + assert_eq!(response.total, 2); + assert_eq!(response.verified_count, 1); + assert_eq!(response.failed_count, 1); + assert_eq!(response.results.len(), 2); + + // Verify first item + assert_eq!(response.results[0].hash, "hash1"); + assert!(response.results[0].verified); + assert_eq!(response.results[0].transaction_id, Some("tx1".to_string())); + assert_eq!(response.results[0].timestamp, Some(1234567890)); + assert_eq!(response.results[0].error, None); + + // Verify second item + assert_eq!(response.results[1].hash, "hash2"); + assert!(!response.results[1].verified); + assert_eq!(response.results[1].transaction_id, None); + assert_eq!(response.results[1].timestamp, None); + assert_eq!( + response.results[1].error, + Some("verification failed".to_string()) + ); + } + + #[test] + fn test_batch_verify_item_creation() { + let item = BatchVerifyItem { + hash: "test_hash".to_string(), + verified: true, + transaction_id: Some("transaction_123".to_string()), + timestamp: Some(1640995200), // 2022-01-01 00:00:00 UTC + error: None, + }; + + assert_eq!(item.hash, "test_hash"); + assert!(item.verified); + assert_eq!(item.transaction_id, Some("transaction_123".to_string())); + assert_eq!(item.timestamp, Some(1640995200)); + assert_eq!(item.error, None); + } + + #[test] + fn test_batch_verify_item_with_error() { + let item = BatchVerifyItem { + hash: "invalid_hash".to_string(), + verified: false, + transaction_id: None, + timestamp: None, + error: Some("invalid hash format".to_string()), + }; + + assert_eq!(item.hash, "invalid_hash"); + assert!(!item.verified); + assert_eq!(item.transaction_id, None); + assert_eq!(item.timestamp, None); + assert_eq!(item.error, Some("invalid hash format".to_string())); + } +} diff --git a/contract/src/main.rs b/contract/src/main.rs index 069ce6c..ff3b4b8 100644 --- a/contract/src/main.rs +++ b/contract/src/main.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use stellar_doc_verifier::app; use stellar_doc_verifier::cache::{CacheBackend, RedisCache}; -use stellar_doc_verifier::config::AppConfig; +use stellar_doc_verifier::config::{AppConfig, Environment}; use stellar_doc_verifier::metrics::MetricsRegistry; use stellar_doc_verifier::stellar::StellarClient; use stellar_doc_verifier::*; @@ -23,7 +23,22 @@ async fn main() -> Result<(), Box> { config.log_level, config.log_level )) }); - tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + match config.environment { + Environment::Production => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) + .json() + .init(); + } + Environment::Development => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) + .init(); + } + } info!("Starting Stellar Document Verification Service"); diff --git a/contract/src/metrics.rs b/contract/src/metrics.rs index 67c3bb5..86cb81d 100644 --- a/contract/src/metrics.rs +++ b/contract/src/metrics.rs @@ -1,6 +1,7 @@ use axum::response::IntoResponse; use prometheus::{Counter, Encoder, Registry, TextEncoder}; +#[derive(Clone)] pub struct MetricsRegistry { registry: Registry, request_count: Counter, diff --git a/contract/src/routes.rs b/contract/src/routes.rs index b3bd61f..69d2f82 100644 --- a/contract/src/routes.rs +++ b/contract/src/routes.rs @@ -14,11 +14,17 @@ pub fn app(state: AppState) -> Router { .route("/verify", post(verify::verify_document)) .route("/verify/batch", post(verify::batch_verify_documents)) .route("/verify/:hash", get(verify::verify_document_by_hash)) - .route("/verify/:hash/history", get(verify::verify_document_history)) + .route( + "/verify/:hash/history", + get(verify::verify_document_history), + ) .route("/submit", post(submit::submit_document)) .route("/revoke", post(revoke::revoke_document)) .route("/transfer", post(transfer::record_transfer)) - .route("/transfer/:document_hash", get(transfer::get_transfer_history)) + .route( + "/transfer/:document_hash", + get(transfer::get_transfer_history), + ) .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/contract/src/stellar.rs b/contract/src/stellar.rs index 675aba9..1d1edd5 100644 --- a/contract/src/stellar.rs +++ b/contract/src/stellar.rs @@ -123,6 +123,7 @@ impl StellarClient { } } + #[tracing::instrument(name = "stellar.check_connection", skip(self))] pub async fn check_connection(&self) -> bool { self.http_client .get(&self.horizon_url) @@ -135,6 +136,11 @@ impl StellarClient { /// Verifies a document hash against Horizon using the `ManageData` approach. /// /// Reads `account.data_attr` for key `"doc_" + &hash[..58]`. + #[tracing::instrument( + name = "stellar.verify_hash", + skip(self, hash), + fields(hash_prefix = %hash_prefix(hash), anchor_account_id = %anchor_account_id) + )] pub async fn verify_hash( &self, hash: &str, @@ -188,6 +194,11 @@ impl StellarClient { } /// Fetches all ManageData history entries for a given document hash (anchors, updates, transfers). + #[tracing::instrument( + name = "stellar.get_hash_history", + skip(self, hash), + fields(hash_prefix = %hash_prefix(hash), anchor_account_id = %anchor_account_id) + )] pub async fn get_hash_history( &self, hash: &str, @@ -247,6 +258,11 @@ impl StellarClient { } /// Anchor a transfer record on Stellar using a `ManageData` operation. + #[tracing::instrument( + name = "stellar.anchor_transfer", + skip(self, transfer_hash, secret_key), + fields(hash_prefix = %hash_prefix(transfer_hash), public_key = %public_key) + )] pub async fn anchor_transfer( &self, transfer_hash: &str, @@ -359,6 +375,11 @@ impl StellarClient { /// /// # Key format /// `"doc_" + &hash[..58]` — matches NestJS `buildDataKey()`. + #[tracing::instrument( + name = "stellar.anchor_hash", + skip(self, hash, secret_key), + fields(hash_prefix = %hash_prefix(hash), public_key = %public_key) + )] pub async fn anchor_hash( &self, hash: &str, @@ -467,6 +488,11 @@ impl StellarClient { /// /// Key: `"revoked_" + &hash[..56]` (max 64 bytes). /// Value: the revocation JSON payload (truncated to 64 bytes). + #[tracing::instrument( + name = "stellar.anchor_revocation", + skip(self, hash, revocation_json, secret_key), + fields(hash_prefix = %hash_prefix(hash), public_key = %public_key) + )] pub async fn anchor_revocation( &self, hash: &str, @@ -575,6 +601,10 @@ impl StellarClient { } /// Build the ManageData key: `"doc_" + &hash[..58]` (max 62 bytes ≤ 64-byte limit). +fn hash_prefix(hash: &str) -> &str { + &hash[..hash.len().min(16)] +} + pub fn build_data_key(hash: &str) -> String { let suffix_len = hash.len().min(58); format!("doc_{}", &hash[..suffix_len]) diff --git a/contract/src/types.rs b/contract/src/types.rs index 3d9060f..4e9658e 100644 --- a/contract/src/types.rs +++ b/contract/src/types.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; -use crate::hash_validator::{HashValidator, ValidationError as HashValidationError}; +use crate::hash_validator::ValidationError as HashValidationError; use crate::stellar::TransactionRecord; // Application state diff --git a/contract/tests/handler_integration_tests.rs b/contract/tests/handler_integration_tests.rs index 2933749..069f3c4 100644 --- a/contract/tests/handler_integration_tests.rs +++ b/contract/tests/handler_integration_tests.rs @@ -1,12 +1,12 @@ -use axum::http::{Request, StatusCode}; use axum::body::Body; +use axum::http::{Request, StatusCode}; +use std::sync::Arc; use stellar_doc_verifier::app; use stellar_doc_verifier::cache::{CacheBackend, InMemoryCache}; use stellar_doc_verifier::config::AppConfig; use stellar_doc_verifier::metrics::MetricsRegistry; use stellar_doc_verifier::stellar::StellarClient; use stellar_doc_verifier::AppState; -use std::sync::Arc; use tower::ServiceExt; fn test_app_state() -> AppState { @@ -39,7 +39,9 @@ async fn test_health_check_returns_200() { assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json.get("status").is_some()); assert!(json.get("stellar_connected").is_some()); @@ -63,7 +65,9 @@ async fn test_metrics_handler_returns_prometheus_text() { assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); let text = String::from_utf8(body.to_vec()).unwrap(); assert!(text.contains("requests_total")); } @@ -162,7 +166,9 @@ async fn test_submit_with_invalid_hash_returns_400() { .method("POST") .uri("/submit") .header("content-type", "application/json") - .body(Body::from(r#"{"document_hash": "invalid", "document_id": "doc1", "submitter": "test"}"#)) + .body(Body::from( + r#"{"document_hash": "invalid", "document_id": "doc1", "submitter": "test"}"#, + )) .unwrap(), ) .await @@ -182,7 +188,9 @@ async fn test_revoke_with_invalid_hash_returns_400() { .method("POST") .uri("/revoke") .header("content-type", "application/json") - .body(Body::from(r#"{"document_hash": "invalid", "reason": "test", "revoked_by": "admin"}"#)) + .body(Body::from( + r#"{"document_hash": "invalid", "reason": "test", "revoked_by": "admin"}"#, + )) .unwrap(), ) .await