Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions contract/src/handlers/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,156 @@ pub async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyIt
error: None,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{CacheBackend, InMemoryCache};
use crate::metrics::MetricsRegistry;
use crate::stellar::StellarClient;

fn test_state() -> AppState {
AppState {
stellar: StellarClient::new("https://horizon-testnet.stellar.org"),
cache: CacheBackend::InMemory(InMemoryCache::new()),
metrics: MetricsRegistry::new(),
stellar_secret_key: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
}
}

#[tokio::test]
async fn test_verify_single_hash_invalid_length() {
let state = test_state();
let item = verify_single_hash(&state, "short_hash".to_string()).await;
assert_eq!(item.hash, "short_hash");
assert!(!item.verified);
assert!(item.transaction_id.is_none());
assert!(item.timestamp.is_none());
assert!(item.error.is_some());
assert!(item.error.unwrap().contains("wrong length"));
}

#[tokio::test]
async fn test_verify_single_hash_invalid_hex() {
let state = test_state();
let invalid_hex = "z".repeat(64);
let item = verify_single_hash(&state, invalid_hex.clone()).await;
assert_eq!(item.hash, invalid_hex);
assert!(!item.verified);
assert!(item.error.is_some());
assert!(item.error.unwrap().contains("invalid character"));
}

#[tokio::test]
async fn test_verify_single_hash_empty() {
let state = test_state();
let item = verify_single_hash(&state, "".to_string()).await;
assert_eq!(item.hash, "");
assert!(!item.verified);
assert!(item.error.is_some());
assert!(item.error.unwrap().contains("empty"));
}

#[tokio::test]
async fn test_verify_single_hash_cached_hit() {
let state = test_state();
let valid_hash =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string();

let cached_resp = VerifyResponse {
verified: true,
transaction_id: Some("tx_cached_123".to_string()),
timestamp: Some(1700000000),
cached: true,
revoked: None,
revoked_at: None,
};
state
.cache
.set(&valid_hash, &cached_resp, 3600)
.await
.unwrap();

let item = verify_single_hash(&state, valid_hash.clone()).await;
assert_eq!(item.hash, valid_hash);
assert!(item.verified);
assert_eq!(item.transaction_id, Some("tx_cached_123".to_string()));
assert_eq!(item.timestamp, Some(1700000000));
assert!(item.error.is_none());
}

#[tokio::test]
async fn test_batch_verify_mixed_hashes_returns_distinct_results() {
let state = test_state();

let valid_cached_hash =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string();
let cached_resp = VerifyResponse {
verified: true,
transaction_id: Some("tx_valid_cached".to_string()),
timestamp: Some(1700000000),
cached: true,
revoked: None,
revoked_at: None,
};
state
.cache
.set(&valid_cached_hash, &cached_resp, 3600)
.await
.unwrap();

let invalid_short_hash = "abc123".to_string();
let invalid_char_hash = "g".repeat(64);

let req = BatchVerifyRequest {
hashes: vec![
valid_cached_hash.clone(),
invalid_short_hash.clone(),
invalid_char_hash.clone(),
],
};

let response = batch_verify_documents(State(state), Json(req)).await;
assert_eq!(response.status(), StatusCode::OK);

// Convert body to BatchVerifyResponse
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let batch_resp: BatchVerifyResponse = serde_json::from_slice(&body).unwrap();

assert_eq!(batch_resp.total, 3);
assert_eq!(batch_resp.verified_count, 1);
assert_eq!(batch_resp.failed_count, 2);
assert_eq!(batch_resp.results.len(), 3);

// First item (valid, cached): verified = true, error = None
assert_eq!(batch_resp.results[0].hash, valid_cached_hash);
assert!(batch_resp.results[0].verified);
assert_eq!(
batch_resp.results[0].transaction_id,
Some("tx_valid_cached".to_string())
);
assert!(batch_resp.results[0].error.is_none());

// Second item (invalid length): verified = false, error = Some(...)
assert_eq!(batch_resp.results[1].hash, invalid_short_hash);
assert!(!batch_resp.results[1].verified);
assert!(batch_resp.results[1].error.is_some());
assert!(batch_resp.results[1]
.error
.as_ref()
.unwrap()
.contains("wrong length"));

// Third item (invalid character): verified = false, error = Some(...)
assert_eq!(batch_resp.results[2].hash, invalid_char_hash);
assert!(!batch_resp.results[2].verified);
assert!(batch_resp.results[2].error.is_some());
assert!(batch_resp.results[2]
.error
.as_ref()
.unwrap()
.contains("invalid character"));
}
}
30 changes: 15 additions & 15 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ pub struct AppState {
}

// Request/Response types
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct VerifyRequest {
pub document_hash: String,
pub transaction_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct VerifyResponse {
pub verified: bool,
pub transaction_id: Option<String>,
Expand All @@ -101,37 +101,37 @@ pub struct VerifyResponse {
}

/// Request type for submitting a document hash to Stellar blockchain
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SubmitRequest {
pub document_hash: String,
pub document_id: String,
pub submitter: String,
}

/// Response type for document hash submission
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct SubmitResponse {
pub success: bool,
pub transaction_id: Option<String>,
pub anchored_at: Option<i64>,
pub error: Option<String>,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct RevokeRequest {
pub document_hash: String,
pub reason: String,
pub revoked_by: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct RevokeResponse {
pub transaction_id: String,
pub revoked_at: i64,
pub revoked: bool,
}

#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct HealthResponse {
pub status: String,
pub stellar_connected: bool,
Expand All @@ -141,33 +141,33 @@ pub struct HealthResponse {
}

/// Response type for document verification history
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct HistoryResponse {
pub document_hash: String,
pub transactions: Vec<TransactionRecord>,
pub count: usize,
pub cached: bool,
}

#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ValidationErrorResponse {
pub error: String,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct BatchVerifyRequest {
pub hashes: Vec<String>,
}

#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct BatchVerifyResponse {
pub results: Vec<BatchVerifyItem>,
pub total: usize,
pub verified_count: usize,
pub failed_count: usize,
}

#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct BatchVerifyItem {
pub hash: String,
pub verified: bool,
Expand All @@ -176,7 +176,7 @@ pub struct BatchVerifyItem {
pub error: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TransferRequest {
pub document_hash: String,
pub from_owner: String,
Expand All @@ -185,7 +185,7 @@ pub struct TransferRequest {
pub transfer_reference: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TransferRecord {
pub document_hash: String,
pub from_owner: String,
Expand All @@ -197,7 +197,7 @@ pub struct TransferRecord {
pub anchored_at: String,
}

#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TransferResponse {
pub transfer_hash: String,
pub memo: String,
Expand Down
2 changes: 1 addition & 1 deletion contract/src/stellar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct StellarClient {
retry_policy: RetryPolicy,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct TransactionRecord {
pub transaction_id: String,
pub timestamp: i64,
Expand Down
79 changes: 79 additions & 0 deletions contract/src/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,85 @@ async fn batch_verify_calls_horizon_once_per_hash() {
account_mock.assert_hits_async(10).await;
}

#[tokio::test]
async fn batch_verify_mixed_valid_and_invalid_hashes_returns_distinct_results() {
let mock_server = MockServer::start_async().await;

mock_server.mock(|when, then| {
when.method(GET).path_contains("/accounts/");
then.status(200)
.header("content-type", "application/json")
.json_body(horizon_empty_account_json());
});

let state = make_state(&mock_server.base_url());

// Prime cache with a verified document
let verified_hash = SAMPLE_HASH.to_string();
let cached_resp = crate::VerifyResponse {
verified: true,
transaction_id: Some("tx_integration_batch".to_string()),
timestamp: Some(1700000000),
cached: true,
revoked: None,
revoked_at: None,
};
state
.cache
.set(&verified_hash, &cached_resp, 3600)
.await
.unwrap();

let server = TestServer::new(app(state)).unwrap();

let invalid_short = "short";
let invalid_hex = "x".repeat(64);
let valid_unanchored = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";

let resp = server
.post("/verify/batch")
.json(&json!({
"hashes": [
verified_hash,
invalid_short,
invalid_hex,
valid_unanchored
]
}))
.await;

resp.assert_status_ok();
let body: Value = resp.json();

assert_eq!(body["total"], 4);
assert_eq!(body["verified_count"], 1);
assert_eq!(body["failed_count"], 3);

let results = body["results"].as_array().unwrap();
assert_eq!(results.len(), 4);

// Item 0: valid, anchored (cached) -> verified: true, error: null
assert_eq!(results[0]["hash"], SAMPLE_HASH);
assert_eq!(results[0]["verified"], true);
assert_eq!(results[0]["transaction_id"], "tx_integration_batch");
assert!(results[0]["error"].is_null());

// Item 1: invalid length -> verified: false, error: "wrong length"
assert_eq!(results[1]["hash"], invalid_short);
assert_eq!(results[1]["verified"], false);
assert!(results[1]["error"].as_str().unwrap().contains("wrong length"));

// Item 2: invalid hex -> verified: false, error: "invalid character"
assert_eq!(results[2]["hash"], invalid_hex);
assert_eq!(results[2]["verified"], false);
assert!(results[2]["error"].as_str().unwrap().contains("invalid character"));

// Item 3: valid unanchored -> verified: false, error: null
assert_eq!(results[3]["hash"], valid_unanchored);
assert_eq!(results[3]["verified"], false);
assert!(results[3]["error"].is_null());
}

// ─────────────────────────────────────────────────────────────────────────────
// 7. Batch validate: empty batch → 400
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading