diff --git a/contract/Cargo.toml b/contract/Cargo.toml index 5884703..e91debe 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -43,6 +43,8 @@ hex = "0.4" chrono = "0.4.43" base64 = "0.22" rand = "0.8" +uuid = { version = "1", features = ["v4"] } +async-trait = "0.1" [dev-dependencies] httpmock = "0.7" diff --git a/contract/cmmty/per_client_rate_limit/config.rs b/contract/cmmty/per_client_rate_limit/config.rs new file mode 100644 index 0000000..9412f7b --- /dev/null +++ b/contract/cmmty/per_client_rate_limit/config.rs @@ -0,0 +1,96 @@ +//! Rate-limit configuration loaded from environment variables. + +use std::env; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientTier { + Known, + Default, +} + +/// Configuration for per-client rate limiting. +#[derive(Debug, Clone)] +pub struct RateLimitConfig { + /// Max requests per window for unknown/anonymous clients. + pub default_limit: u64, + /// Max requests per window for known (whitelisted) clients. + pub known_client_limit: u64, + /// Window size in seconds. + pub window_secs: u64, + /// List of known client IDs (from `KNOWN_CLIENT_IDS` env var, comma-separated). + pub known_clients: Vec, +} + +impl RateLimitConfig { + /// Load from environment variables with sensible defaults. + /// + /// | Variable | Default | + /// |-----------------------|---------| + /// | `RL_DEFAULT_LIMIT` | 60 | + /// | `RL_KNOWN_LIMIT` | 600 | + /// | `RL_WINDOW_SECS` | 60 | + /// | `KNOWN_CLIENT_IDS` | (empty) | + pub fn from_env() -> Self { + let default_limit = env_u64("RL_DEFAULT_LIMIT", 60); + let known_client_limit = env_u64("RL_KNOWN_LIMIT", 600); + let window_secs = env_u64("RL_WINDOW_SECS", 60); + let known_clients = env::var("KNOWN_CLIENT_IDS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(); + + Self { + default_limit, + known_client_limit, + window_secs, + known_clients, + } + } + + /// Determine the tier for a given client ID. + pub fn tier_for(&self, client_id: &str) -> ClientTier { + if self.known_clients.iter().any(|k| k == client_id) { + ClientTier::Known + } else { + ClientTier::Default + } + } +} + +fn env_u64(key: &str, default: u64) -> u64 { + env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_for_known_client() { + let cfg = RateLimitConfig { + default_limit: 10, + known_client_limit: 100, + window_secs: 60, + known_clients: vec!["client-a".to_string()], + }; + assert_eq!(cfg.tier_for("client-a"), ClientTier::Known); + assert_eq!(cfg.tier_for("client-b"), ClientTier::Default); + } + + #[test] + fn tier_for_empty_known_list() { + let cfg = RateLimitConfig { + default_limit: 10, + known_client_limit: 100, + window_secs: 60, + known_clients: vec![], + }; + assert_eq!(cfg.tier_for("anyone"), ClientTier::Default); + } +} diff --git a/contract/cmmty/per_client_rate_limit/mod.rs b/contract/cmmty/per_client_rate_limit/mod.rs new file mode 100644 index 0000000..eda197e --- /dev/null +++ b/contract/cmmty/per_client_rate_limit/mod.rs @@ -0,0 +1,314 @@ +//! Per-client configurable rate limiting (SC-12). +//! +//! Reads `X-Client-Id` from each request and applies either a "known-client" +//! limit or a default limit, both configured via environment variables. +//! Per-client counters are stored in Redis (or an in-memory fallback for tests). +//! +//! Response headers set on every request: +//! `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` + +pub mod config; + +use axum::{body::Body, extract::Request, http::HeaderValue, response::Response}; +use futures::future::BoxFuture; +use std::{ + sync::Arc, + task::{Context, Poll}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tower::{Layer, Service}; + +pub use config::RateLimitConfig; +use config::ClientTier; + +// ── Redis counter store ─────────────────────────────────────────────────────── + +/// Minimal async counter backed by Redis (or in-memory for tests). +#[async_trait::async_trait] +pub trait CounterStore: Send + Sync { + /// Increment the counter for `key` and return (new_count, window_reset_unix_secs). + /// The window is `window_secs` seconds wide. + async fn increment(&self, key: &str, window_secs: u64) -> anyhow::Result<(u64, u64)>; +} + +/// Redis-backed counter store. +pub struct RedisCounterStore { + client: redis::Client, +} + +impl RedisCounterStore { + pub fn new(redis_url: &str) -> anyhow::Result { + Ok(Self { + client: redis::Client::open(redis_url)?, + }) + } +} + +#[async_trait::async_trait] +impl CounterStore for RedisCounterStore { + async fn increment(&self, key: &str, window_secs: u64) -> anyhow::Result<(u64, u64)> { + use redis::AsyncCommands; + let mut conn = self.client.get_multiplexed_async_connection().await?; + + let count: u64 = conn.incr(key, 1u64).await?; + if count == 1 { + conn.expire(key, window_secs as i64).await?; + } + let ttl: i64 = conn.ttl(key).await?; + let reset = now_unix() + ttl.max(0) as u64; + Ok((count, reset)) + } +} + +/// In-memory counter store (for tests / no-Redis environments). +#[derive(Default)] +pub struct InMemoryCounterStore { + store: Arc>>, +} + +#[async_trait::async_trait] +impl CounterStore for InMemoryCounterStore { + async fn increment(&self, key: &str, window_secs: u64) -> anyhow::Result<(u64, u64)> { + let mut map = self.store.lock().await; + let now = now_unix(); + let entry = map.entry(key.to_string()).or_insert((0, now + window_secs)); + // Reset window if expired + if now >= entry.1 { + *entry = (0, now + window_secs); + } + entry.0 += 1; + Ok((entry.0, entry.1)) + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// ── Tower layer ─────────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct PerClientRateLimitLayer { + config: Arc, + store: Arc, +} + +impl PerClientRateLimitLayer { + pub fn new(config: RateLimitConfig, store: Arc) -> Self { + Self { + config: Arc::new(config), + store, + } + } +} + +impl Layer for PerClientRateLimitLayer { + type Service = PerClientRateLimitMiddleware; + fn layer(&self, inner: S) -> Self::Service { + PerClientRateLimitMiddleware { + inner, + config: self.config.clone(), + store: self.store.clone(), + } + } +} + +#[derive(Clone)] +pub struct PerClientRateLimitMiddleware { + inner: S, + config: Arc, + store: Arc, +} + +impl Service> for PerClientRateLimitMiddleware +where + S: Service, Response = Response> + Send + Clone + 'static, + S::Future: Send + 'static, + S::Error: Into>, +{ + type Response = Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let client_id = req + .headers() + .get("x-client-id") + .and_then(|v| v.to_str().ok()) + .unwrap_or("anonymous") + .to_string(); + + let tier = self.config.tier_for(&client_id); + let limit = match tier { + ClientTier::Known => self.config.known_client_limit, + ClientTier::Default => self.config.default_limit, + }; + let window = self.config.window_secs; + let counter_key = format!("rl:{}", client_id); + + let store = self.store.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + let (count, reset) = store + .increment(&counter_key, window) + .await + .unwrap_or((limit + 1, now_unix() + window)); // fail-open: deny on store error + + let remaining = limit.saturating_sub(count); + + if count > limit { + let mut resp = Response::builder() + .status(axum::http::StatusCode::TOO_MANY_REQUESTS) + .body(Body::from("rate limit exceeded")) + .unwrap(); + set_rl_headers(resp.headers_mut(), limit, 0, reset); + return Ok(resp); + } + + let result = inner.call(req).await?; + let mut resp = result; + set_rl_headers(resp.headers_mut(), limit, remaining, reset); + Ok(resp) + }) + } +} + +fn set_rl_headers( + headers: &mut axum::http::HeaderMap, + limit: u64, + remaining: u64, + reset: u64, +) { + headers.insert( + "x-ratelimit-limit", + HeaderValue::from_str(&limit.to_string()).unwrap(), + ); + headers.insert( + "x-ratelimit-remaining", + HeaderValue::from_str(&remaining.to_string()).unwrap(), + ); + headers.insert( + "x-ratelimit-reset", + HeaderValue::from_str(&reset.to_string()).unwrap(), + ); +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request, routing::get, Router}; + use tower::ServiceExt; + + fn make_layer() -> PerClientRateLimitLayer { + let cfg = RateLimitConfig { + default_limit: 3, + known_client_limit: 10, + window_secs: 60, + known_clients: vec!["vip-client".to_string()], + }; + let store = Arc::new(InMemoryCounterStore::default()); + PerClientRateLimitLayer::new(cfg, store) + } + + fn app_with_layer(layer: PerClientRateLimitLayer) -> Router { + Router::new() + .route("/", get(|| async { "ok" })) + .layer(layer) + } + + async fn send(app: Router, client_id: Option<&str>) -> axum::http::StatusCode { + let mut builder = Request::builder().uri("/"); + if let Some(id) = client_id { + builder = builder.header("x-client-id", id); + } + let req = builder.body(Body::empty()).unwrap(); + app.oneshot(req).await.unwrap().status() + } + + #[tokio::test] + async fn default_client_allowed_within_limit() { + let app = app_with_layer(make_layer()); + let status = send(app, Some("unknown-client")).await; + assert_eq!(status, axum::http::StatusCode::OK); + } + + #[tokio::test] + async fn default_client_blocked_after_limit() { + let store = Arc::new(InMemoryCounterStore::default()); + let cfg = RateLimitConfig { + default_limit: 2, + known_client_limit: 100, + window_secs: 60, + known_clients: vec![], + }; + let layer = PerClientRateLimitLayer::new(cfg, store); + + // Share the same store across requests by building the router once + let router = Router::new() + .route("/", get(|| async { "ok" })) + .layer(layer); + + // First two requests should pass + for _ in 0..2 { + let req = Request::builder() + .uri("/") + .header("x-client-id", "test-client") + .body(Body::empty()) + .unwrap(); + let status = router.clone().oneshot(req).await.unwrap().status(); + assert_eq!(status, axum::http::StatusCode::OK); + } + + // Third request should be rate-limited + let req = Request::builder() + .uri("/") + .header("x-client-id", "test-client") + .body(Body::empty()) + .unwrap(); + let status = router.clone().oneshot(req).await.unwrap().status(); + assert_eq!(status, axum::http::StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn known_client_gets_higher_limit() { + let cfg = RateLimitConfig { + default_limit: 1, + known_client_limit: 100, + window_secs: 60, + known_clients: vec!["vip".to_string()], + }; + assert_eq!(cfg.tier_for("vip"), ClientTier::Known); + assert_eq!(cfg.tier_for("other"), ClientTier::Default); + } + + #[tokio::test] + async fn response_includes_ratelimit_headers() { + let app = app_with_layer(make_layer()); + let req = Request::builder() + .uri("/") + .header("x-client-id", "header-test") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert!(resp.headers().contains_key("x-ratelimit-limit")); + assert!(resp.headers().contains_key("x-ratelimit-remaining")); + assert!(resp.headers().contains_key("x-ratelimit-reset")); + } + + #[tokio::test] + async fn anonymous_client_uses_default_limit() { + let app = app_with_layer(make_layer()); + let status = send(app, None).await; + assert_eq!(status, axum::http::StatusCode::OK); + } +} diff --git a/contract/cmmty/structured_logging/config.rs b/contract/cmmty/structured_logging/config.rs new file mode 100644 index 0000000..431aabd --- /dev/null +++ b/contract/cmmty/structured_logging/config.rs @@ -0,0 +1,18 @@ +//! Logging configuration loaded from environment variables. + +use std::env; + +/// Configuration for the structured JSON logger. +#[derive(Debug, Clone)] +pub struct LogConfig { + /// Tracing filter directive, e.g. `"info"` or `"stellar_doc_verifier=debug"`. + pub log_level: String, +} + +impl Default for LogConfig { + fn default() -> Self { + Self { + log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()), + } + } +} diff --git a/contract/cmmty/structured_logging/mod.rs b/contract/cmmty/structured_logging/mod.rs new file mode 100644 index 0000000..fc7206e --- /dev/null +++ b/contract/cmmty/structured_logging/mod.rs @@ -0,0 +1,190 @@ +//! Structured JSON logging middleware (SC-11). +//! +//! Provides: +//! - `init_json_logging()` — initialise tracing-subscriber with JSON output. +//! - `LoggingLayer` / `LoggingMiddleware` — Axum-compatible Tower middleware +//! that emits one JSON log line per request containing: +//! `timestamp`, `method`, `path`, `status_code`, `duration_ms`, `request_id`. +//! - `log_stellar_op()` — helper for Stellar interaction log lines. + +pub mod config; + +use axum::{body::Body, extract::Request, response::Response}; +use futures::future::BoxFuture; +use std::{ + task::{Context, Poll}, + time::Instant, +}; +use tower::{Layer, Service}; +use tracing::{error, info}; +use uuid::Uuid; + +pub use config::LogConfig; + +/// Initialise a global tracing subscriber that emits JSON to stdout. +/// Call once at application startup. +pub fn init_json_logging(config: &LogConfig) { + use tracing_subscriber::{fmt, EnvFilter}; + + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(&config.log_level)); + + fmt() + .json() + .with_env_filter(filter) + .with_current_span(false) + .init(); +} + +// ── Tower layer ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Default)] +pub struct LoggingLayer; + +impl Layer for LoggingLayer { + type Service = LoggingMiddleware; + fn layer(&self, inner: S) -> Self::Service { + LoggingMiddleware { inner } + } +} + +#[derive(Clone)] +pub struct LoggingMiddleware { + inner: S, +} + +impl Service> for LoggingMiddleware +where + S: Service, Response = Response> + Send + Clone + 'static, + S::Future: Send + 'static, + S::Error: std::fmt::Debug, +{ + type Response = Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + let request_id = Uuid::new_v4().to_string(); + let start = Instant::now(); + + let mut inner = self.inner.clone(); + Box::pin(async move { + let result = inner.call(req).await; + let duration_ms = start.elapsed().as_millis(); + + match &result { + Ok(resp) => { + let status = resp.status().as_u16(); + info!( + timestamp = %chrono::Utc::now().to_rfc3339(), + method = %method, + path = %path, + status_code = status, + duration_ms = duration_ms, + request_id = %request_id, + "request completed" + ); + } + Err(e) => { + error!( + timestamp = %chrono::Utc::now().to_rfc3339(), + method = %method, + path = %path, + duration_ms = duration_ms, + request_id = %request_id, + error = ?e, + "request error" + ); + } + } + + result + }) + } +} + +// ── Stellar interaction logger ──────────────────────────────────────────────── + +/// Log a Stellar operation result. +/// +/// - `operation`: e.g. `"verify_hash"`, `"anchor_transfer"` +/// - `hash`: full hash string — will be truncated to 16 chars in the log +/// - `success`: whether the operation succeeded +/// - `ledger`: ledger sequence number on success, `None` otherwise +pub fn log_stellar_op(operation: &str, hash: &str, success: bool, ledger: Option) { + let truncated = &hash[..hash.len().min(16)]; + if success { + info!( + timestamp = %chrono::Utc::now().to_rfc3339(), + operation = %operation, + hash = %truncated, + success = true, + ledger = ledger, + "stellar operation succeeded" + ); + } else { + error!( + timestamp = %chrono::Utc::now().to_rfc3339(), + operation = %operation, + hash = %truncated, + success = false, + "stellar operation failed" + ); + } +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request, routing::get, Router}; + use tower::ServiceExt; + + fn test_router() -> Router { + Router::new() + .route("/ping", get(|| async { "pong" })) + .layer(LoggingLayer) + } + + #[tokio::test] + async fn middleware_passes_request_through() { + let app = test_router(); + let req = Request::builder() + .uri("/ping") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + } + + #[tokio::test] + async fn middleware_returns_404_for_unknown_route() { + let app = test_router(); + let req = Request::builder() + .uri("/unknown") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND); + } + + #[test] + fn log_stellar_op_truncates_hash() { + // Just verify it doesn't panic with a short or long hash + log_stellar_op("verify_hash", "abcdef1234567890abcdef", true, Some(12345)); + log_stellar_op("anchor_transfer", "short", false, None); + } + + #[test] + fn log_config_defaults() { + let cfg = LogConfig::default(); + assert_eq!(cfg.log_level, "info"); + } +} diff --git a/contract/cmmty/tests/hash_validator_tests.rs b/contract/cmmty/tests/hash_validator_tests.rs new file mode 100644 index 0000000..1701d00 --- /dev/null +++ b/contract/cmmty/tests/hash_validator_tests.rs @@ -0,0 +1,196 @@ +//! Expanded unit tests for hash_validator module (SC-10). +//! +//! Run with: cargo test --manifest-path contract/Cargo.toml + +use stellar_doc_verifier::hash_validator::{HashAlgorithm, HashValidator, ValidationError}; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn valid_sha256() -> &'static str { + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +} + +fn valid_sha512() -> &'static str { + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\ + 47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" +} + +// ── validate_sha256 ─────────────────────────────────────────────────────────── + +#[test] +fn sha256_valid_hash_passes() { + assert!(HashValidator::validate_sha256(valid_sha256()).is_ok()); +} + +#[test] +fn sha256_empty_string_returns_empty_hash_error() { + assert!(matches!( + HashValidator::validate_sha256(""), + Err(ValidationError::EmptyHash) + )); +} + +#[test] +fn sha256_whitespace_only_returns_empty_hash_error() { + assert!(matches!( + HashValidator::validate_sha256(" "), + Err(ValidationError::EmptyHash) + )); +} + +#[test] +fn sha256_wrong_length_short() { + let hash = "a".repeat(63); + assert!(matches!( + HashValidator::validate_sha256(&hash), + Err(ValidationError::WrongLength { expected: 64, actual: 63 }) + )); +} + +#[test] +fn sha256_wrong_length_long() { + let hash = "a".repeat(65); + assert!(matches!( + HashValidator::validate_sha256(&hash), + Err(ValidationError::WrongLength { expected: 64, actual: 65 }) + )); +} + +#[test] +fn sha256_invalid_char_reports_position() { + let mut hash = valid_sha256().to_string(); + hash.replace_range(5..6, "z"); + match HashValidator::validate_sha256(&hash) { + Err(ValidationError::InvalidCharacter { position: 5, character: 'z' }) => {} + other => panic!("unexpected: {:?}", other), + } +} + +#[test] +fn sha256_uppercase_input_passes_after_normalization() { + let upper = valid_sha256().to_uppercase(); + let normalized = HashValidator::normalize(&upper); + assert!(HashValidator::validate_sha256(&normalized).is_ok()); +} + +#[test] +fn sha256_uppercase_input_fails_without_normalization() { + // 'A'–'F' are not valid hex chars in the validator (expects lowercase) + let upper = valid_sha256().to_uppercase(); + assert!(HashValidator::validate_sha256(&upper).is_err()); +} + +// ── validate_sha512 ─────────────────────────────────────────────────────────── + +#[test] +fn sha512_valid_hash_passes() { + assert!(HashValidator::validate_sha512(valid_sha512()).is_ok()); +} + +#[test] +fn sha512_empty_string_returns_empty_hash_error() { + assert!(matches!( + HashValidator::validate_sha512(""), + Err(ValidationError::EmptyHash) + )); +} + +#[test] +fn sha512_wrong_length_short() { + let hash = "a".repeat(127); + assert!(matches!( + HashValidator::validate_sha512(&hash), + Err(ValidationError::WrongLength { expected: 128, actual: 127 }) + )); +} + +#[test] +fn sha512_wrong_length_long() { + let hash = "a".repeat(129); + assert!(matches!( + HashValidator::validate_sha512(&hash), + Err(ValidationError::WrongLength { expected: 128, actual: 129 }) + )); +} + +#[test] +fn sha512_invalid_char_reports_position() { + let mut hash = valid_sha512().to_string(); + hash.replace_range(10..11, "x"); + match HashValidator::validate_sha512(&hash) { + Err(ValidationError::InvalidCharacter { position: 10, character: 'x' }) => {} + other => panic!("unexpected: {:?}", other), + } +} + +#[test] +fn sha512_uppercase_input_passes_after_normalization() { + let upper = valid_sha512().to_uppercase(); + let normalized = HashValidator::normalize(&upper); + assert!(HashValidator::validate_sha512(&normalized).is_ok()); +} + +// ── detect_algorithm ───────────────────────────────────────────────────────── + +#[test] +fn detect_algorithm_sha256() { + assert_eq!( + HashValidator::detect_algorithm(valid_sha256()), + Some(HashAlgorithm::SHA256) + ); +} + +#[test] +fn detect_algorithm_sha512() { + assert_eq!( + HashValidator::detect_algorithm(valid_sha512()), + Some(HashAlgorithm::SHA512) + ); +} + +#[test] +fn detect_algorithm_ambiguous_returns_none() { + assert_eq!(HashValidator::detect_algorithm("abc123"), None); +} + +#[test] +fn detect_algorithm_empty_returns_none() { + assert_eq!(HashValidator::detect_algorithm(""), None); +} + +#[test] +fn detect_algorithm_uppercase_sha256_detected_after_normalize() { + let upper = valid_sha256().to_uppercase(); + let normalized = HashValidator::normalize(&upper); + assert_eq!( + HashValidator::detect_algorithm(&normalized), + Some(HashAlgorithm::SHA256) + ); +} + +// ── normalize ──────────────────────────────────────────────────────────────── + +#[test] +fn normalize_trims_leading_and_trailing_whitespace() { + assert_eq!(HashValidator::normalize(" abc "), "abc"); +} + +#[test] +fn normalize_lowercases() { + assert_eq!(HashValidator::normalize("ABCDEF"), "abcdef"); +} + +#[test] +fn normalize_trims_and_lowercases_combined() { + assert_eq!(HashValidator::normalize(" ABCDEF123 "), "abcdef123"); +} + +#[test] +fn normalize_empty_string_stays_empty() { + assert_eq!(HashValidator::normalize(""), ""); +} + +#[test] +fn normalize_already_normalized_is_unchanged() { + assert_eq!(HashValidator::normalize(valid_sha256()), valid_sha256()); +} diff --git a/contract/cmmty/tests/verify_integration.rs b/contract/cmmty/tests/verify_integration.rs new file mode 100644 index 0000000..6c8dbc6 --- /dev/null +++ b/contract/cmmty/tests/verify_integration.rs @@ -0,0 +1,205 @@ +//! Integration tests for the verify endpoint (SC-09). +//! +//! Spins up an in-process Axum server with an InMemory cache and a mock +//! Stellar client so every test is deterministic and requires no external +//! services. Run with: cargo test --manifest-path contract/Cargo.toml + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tower::ServiceExt; // for `oneshot` + +use stellar_doc_verifier::{ + app, + cache::{CacheBackend, InMemoryCache}, + metrics::MetricsRegistry, + stellar::{StellarClient, VerificationResult}, + AppState, VerifyResponse, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// A valid 64-char lowercase hex SHA-256 hash. +const KNOWN_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +const UNKNOWN_HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const MALFORMED_HASH: &str = "not-a-valid-hash"; + +fn build_state() -> AppState { + AppState { + stellar: Arc::new(StellarClient::new("http://127.0.0.1:1")), // unreachable; tests use cache + cache: Arc::new(CacheBackend::InMemory(InMemoryCache::new())), + metrics: Arc::new(MetricsRegistry::new()), + stellar_secret_key: String::new(), + } +} + +async fn post_verify(state: AppState, hash: &str) -> (StatusCode, Value) { + let router = app(state); + let body = json!({ "document_hash": hash }).to_string(); + let req = Request::builder() + .method("POST") + .uri("/verify") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, json) +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +/// Malformed hash → 400 Bad Request. +#[tokio::test] +async fn verify_malformed_hash_returns_400() { + let (status, body) = post_verify(build_state(), MALFORMED_HASH).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body["error"].as_str().is_some(), "expected error field"); +} + +/// Hash not found in cache and Stellar returns not-verified → verified=false. +#[tokio::test] +async fn verify_unknown_hash_returns_not_verified() { + // The mock StellarClient hits an unreachable URL and returns an error, + // which the handler maps to 500. Pre-seed the cache with a not-verified + // entry so the handler returns a clean 200 verified=false. + let state = build_state(); + let not_found = VerifyResponse { + verified: false, + transaction_id: None, + timestamp: None, + cached: false, + }; + state + .cache + .set(UNKNOWN_HASH, ¬_found, 60) + .await + .unwrap(); + + let (status, body) = post_verify(state, UNKNOWN_HASH).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["verified"], false); +} + +/// Hash found in cache → verified=true, cached=true, txId present. +#[tokio::test] +async fn verify_cached_hash_returns_verified_true_and_cached_flag() { + let state = build_state(); + let cached_entry = VerifyResponse { + verified: true, + transaction_id: Some("tx_abc123".to_string()), + timestamp: Some(1_700_000_000), + cached: true, + }; + state + .cache + .set(KNOWN_HASH, &cached_entry, 3600) + .await + .unwrap(); + + let (status, body) = post_verify(state, KNOWN_HASH).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["verified"], true); + assert_eq!(body["transaction_id"], "tx_abc123"); + assert_eq!(body["cached"], true); +} + +/// Cache hit must NOT call Stellar — verified by seeding cache and pointing +/// Stellar at an unreachable host; if Stellar were called the test would 500. +#[tokio::test] +async fn verify_cache_hit_does_not_call_stellar() { + let state = build_state(); // Stellar URL is unreachable + let cached_entry = VerifyResponse { + verified: true, + transaction_id: Some("tx_cached".to_string()), + timestamp: Some(1_000_000), + cached: true, + }; + state + .cache + .set(KNOWN_HASH, &cached_entry, 3600) + .await + .unwrap(); + + // If Stellar were called this would return 500; getting 200 proves cache was used. + let (status, _) = post_verify(state, KNOWN_HASH).await; + assert_eq!(status, StatusCode::OK); +} + +/// Uppercase hash is normalised before lookup — same cache entry is found. +#[tokio::test] +async fn verify_uppercase_hash_is_normalised() { + let state = build_state(); + let cached_entry = VerifyResponse { + verified: true, + transaction_id: Some("tx_upper".to_string()), + timestamp: Some(999), + cached: true, + }; + // Seed with lowercase key + state + .cache + .set(KNOWN_HASH, &cached_entry, 3600) + .await + .unwrap(); + + // Submit uppercase — should still hit the cache + let (status, body) = post_verify(state, &KNOWN_HASH.to_uppercase()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["verified"], true); +} + +/// GET /verify/:hash — same validation rules apply. +#[tokio::test] +async fn get_verify_by_hash_malformed_returns_400() { + let router = app(build_state()); + let req = Request::builder() + .method("GET") + .uri("/verify/not-a-hash") + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +/// GET /verify/:hash with a cached entry returns 200 verified=true. +#[tokio::test] +async fn get_verify_by_hash_cached_returns_verified() { + let state = build_state(); + let cached_entry = VerifyResponse { + verified: true, + transaction_id: Some("tx_get".to_string()), + timestamp: Some(42), + cached: true, + }; + state + .cache + .set(KNOWN_HASH, &cached_entry, 3600) + .await + .unwrap(); + + let router = app(state); + let req = Request::builder() + .method("GET") + .uri(format!("/verify/{}", KNOWN_HASH)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["verified"], true); + assert_eq!(body["transaction_id"], "tx_get"); +}