#[path = "../src/client.rs"]
mod client;
mod common;
use client::{DeployerClient, DeploymentInfo, FileUploadInfo, HealthResponse, UploadResponse};
use common::{fixture_keypair, mock_deployer};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use sha2::{Digest, Sha256};
use solana_sdk::signer::Signer;
use std::sync::Arc;
use wiremock::matchers::{body_json, header_exists, method, path, query_param};
use wiremock::{Mock, ResponseTemplate};
const AUTH_VERSION_TAG: &str = "solignition-auth-v1";
const EMPTY_BODY_HASH: &str =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex::encode(digest)
}
fn require_auth_headers(req: &wiremock::Request) -> (String, String, String, String) {
let h = |name: &str| -> String {
req.headers
.get(name)
.unwrap_or_else(|| panic!("missing header {name}"))
.to_str()
.expect("header is ASCII")
.to_string()
};
(
h("x-auth-pubkey"),
h("x-auth-timestamp"),
h("x-auth-nonce"),
h("x-auth-signature"),
)
}
#[tokio::test]
async fn health_parses_slim_v1_body() {
let (server, uri, _signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "ok"
})))
.mount(&server)
.await;
let client = DeployerClient::new_anonymous(&uri);
let h: HealthResponse = client.health().await.expect("health succeeds");
assert_eq!(h.status, "ok");
assert!(h.active_loans.is_none());
assert!(h.total_deployments.is_none());
assert!(h.timestamp.is_none());
}
#[tokio::test]
async fn health_backward_compat_parses_old_shape() {
let (server, uri, _signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/health"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "healthy",
"activeLoans": 5,
"totalDeployments": 42,
"timestamp": "2025-01-01T00:00:00Z"
})))
.mount(&server)
.await;
let client = DeployerClient::new_anonymous(&uri);
let h = client.health().await.expect("legacy /health parses");
assert_eq!(h.status, "healthy");
assert_eq!(h.active_loans, Some(5));
assert_eq!(h.total_deployments, Some(42));
assert!(h.timestamp.is_some());
}
#[tokio::test]
async fn get_upload_hits_v1_path_with_auth_headers() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/v1/uploads/abc123"))
.and(header_exists("x-auth-pubkey"))
.and(header_exists("x-auth-signature"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"fileId": "abc123",
"borrower": "ExampleBorrowerPubkey1111111111111111111111",
"fileName": "program.so",
"fileSize": 1024_u64,
"binaryHash": EMPTY_BODY_HASH,
"estimatedCost": 0.5_f64,
"status": "ready",
"createdAt": 1_700_000_000_u64,
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let info: FileUploadInfo = client.get_upload("abc123").await.expect("get_upload succeeds");
assert_eq!(info.file_id, "abc123");
assert_eq!(info.status, "ready");
}
#[tokio::test]
async fn get_uploads_by_borrower_unwraps_paginated_envelope() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/v1/uploads"))
.and(query_param("borrower", "WalletA111111111111111111111111111111111111"))
.and(query_param("limit", "200"))
.and(query_param("offset", "0"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"uploads": [
{
"fileId": "f1f1f1f1f1f1f1f1",
"borrower": "WalletA111111111111111111111111111111111111",
"fileName": "a.so",
"fileSize": 10_u64,
"binaryHash": EMPTY_BODY_HASH,
"estimatedCost": 0.1_f64,
"status": "ready",
"createdAt": 1_700_000_000_u64
}
],
"total": 1,
"limit": 200,
"offset": 0,
"hasMore": false
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let uploads = client
.get_uploads_by_borrower("WalletA111111111111111111111111111111111111")
.await
.expect("list succeeds");
assert_eq!(uploads.len(), 1);
assert_eq!(uploads[0].file_id, "f1f1f1f1f1f1f1f1");
}
#[tokio::test]
async fn notify_loan_posts_to_v1_loans_with_full_body() {
let (server, uri, signer) = mock_deployer().await;
let expected_body = serde_json::json!({
"signature": "sig123",
"borrower": "BorrowerB1111111111111111111111111111111111",
"loanId": "42",
"fileId": "abc123"
});
Mock::given(method("POST"))
.and(path("/v1/loans"))
.and(body_json(expected_body))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"success": true,
"message": "ok",
"signature": "sig123",
"fileId": "abc123"
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let resp = client
.notify_loan(
"sig123",
"BorrowerB1111111111111111111111111111111111",
"42",
"abc123",
)
.await
.expect("notify_loan succeeds");
assert!(resp.success);
}
#[tokio::test]
async fn notify_repaid_puts_loan_id_in_url_not_body() {
let (server, uri, signer) = mock_deployer().await;
let expected_body = serde_json::json!({
"signature": "sigR",
"borrower": "BorrowerC1111111111111111111111111111111111"
});
Mock::given(method("POST"))
.and(path("/v1/loans/42/repayments"))
.and(body_json(expected_body))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"success": true,
"message": "ok",
"loanId": "42",
"auth": "BorrowerC1111111111111111111111111111111111"
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let resp = client
.notify_repaid("sigR", "BorrowerC1111111111111111111111111111111111", 42)
.await
.expect("notify_repaid succeeds");
assert!(resp.success);
assert_eq!(resp.loan_id.as_deref(), Some("42"));
}
#[tokio::test]
async fn get_deployment_parses_response_with_optional_fields() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/v1/deployments/42"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"loanId": "42",
"borrower": "BorrowerD1111111111111111111111111111111111",
"status": "deployed",
"createdAt": 1_700_000_000_u64,
"updatedAt": 1_700_000_500_u64,
"principal": "1000000000",
"programAccountOpen": true,
"programId": "ProgIdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let d: DeploymentInfo = client.get_deployment("42").await.expect("get_deployment succeeds");
assert_eq!(d.status, "deployed");
assert_eq!(d.program_id.as_deref(), Some("ProgIdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
}
#[tokio::test]
async fn get_deployments_by_borrower_unwraps_paginated_envelope() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/v1/deployments"))
.and(query_param("borrower", "WalletE111111111111111111111111111111111111"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"deployments": [
{
"loanId": "1",
"borrower": "WalletE111111111111111111111111111111111111",
"status": "deployed",
"createdAt": 1_700_000_000_u64,
"updatedAt": 1_700_000_500_u64,
"principal": "1000000000",
"programAccountOpen": false
}
],
"total": 1,
"limit": 200,
"offset": 0,
"hasMore": false
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, signer);
let deployments = client
.get_deployments_by_borrower("WalletE111111111111111111111111111111111111")
.await
.expect("deployments list succeeds");
assert_eq!(deployments.len(), 1);
assert_eq!(deployments[0].loan_id, "1");
}
#[tokio::test]
async fn upload_sends_multipart_with_expected_hash() {
let (server, uri, signer) = mock_deployer().await;
let file_bytes = b"hello-world".to_vec();
let file_hash = sha256_hex(&file_bytes);
Mock::given(method("POST"))
.and(path("/v1/uploads"))
.and(header_exists("x-auth-pubkey"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"success": true,
"fileId": "newfileid0000000",
"estimatedCost": 0.5_f64,
"binaryHash": file_hash.clone(),
"message": "ok"
})))
.mount(&server)
.await;
let tmp = std::env::temp_dir().join("solignition-cli-upload-test.so");
std::fs::write(&tmp, &file_bytes).expect("write tmp file");
let client = DeployerClient::new(&uri, signer);
let resp: UploadResponse = client
.upload_file(&tmp, "BorrowerF1111111111111111111111111111111111")
.await
.expect("upload succeeds");
assert_eq!(resp.file_id, "newfileid0000000");
assert_eq!(resp.binary_hash, file_hash);
let recorded = server.received_requests().await.unwrap();
let upload_req = recorded
.iter()
.find(|r| r.url.path() == "/v1/uploads")
.expect("upload request recorded");
let body_str = std::str::from_utf8(&upload_req.body).unwrap_or("");
assert!(body_str.contains("name=\"borrower\""), "borrower field present");
assert!(body_str.contains("name=\"file\""), "file field present");
assert!(body_str.contains("name=\"expectedHash\""), "expectedHash field present");
assert!(body_str.contains(&file_hash), "expectedHash value matches computed hash");
let _ = std::fs::remove_file(&tmp);
}
#[tokio::test]
async fn upload_surfaces_server_side_hash_mismatch_error() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("POST"))
.and(path("/v1/uploads"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"error": "Computed sha256 does not match supplied expectedHash",
"code": "hash_mismatch",
"requestId": "req-x"
})))
.mount(&server)
.await;
let file_bytes = b"corrupted".to_vec();
let tmp = std::env::temp_dir().join("solignition-cli-upload-mismatch.so");
std::fs::write(&tmp, &file_bytes).expect("write tmp file");
let client = DeployerClient::new(&uri, signer);
let err = client
.upload_file(&tmp, "BorrowerG1111111111111111111111111111111111")
.await
.expect_err("upload must fail when server reports hash_mismatch");
let msg = err.to_string();
assert!(msg.contains("422") || msg.contains("hash_mismatch"),
"error message should surface the server response; got: {msg}");
let _ = std::fs::remove_file(&tmp);
}
#[tokio::test]
async fn auth_signature_verifies_against_canonical_message() {
let (server, uri, signer) = mock_deployer().await;
Mock::given(method("GET"))
.and(path("/v1/uploads/canary000000000"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"fileId": "canary000000000",
"borrower": "BorrowerH1111111111111111111111111111111111",
"fileName": "x.so",
"fileSize": 0_u64,
"binaryHash": EMPTY_BODY_HASH,
"estimatedCost": 0.0_f64,
"status": "ready",
"createdAt": 0_u64
})))
.mount(&server)
.await;
let client = DeployerClient::new(&uri, Arc::clone(&signer));
let _ = client.get_upload("canary000000000").await.expect("call succeeds");
let recorded = server.received_requests().await.unwrap();
let req = recorded
.iter()
.find(|r| r.url.path() == "/v1/uploads/canary000000000")
.expect("auth-canary request recorded");
let (pubkey_b58, timestamp_ms, nonce_b58, signature_b58) = require_auth_headers(req);
let expected_pubkey = bs58::encode(fixture_keypair().pubkey().to_bytes()).into_string();
assert_eq!(pubkey_b58, expected_pubkey);
let canonical = format!(
"{tag}\n{method}\n{path}\n{ts}\n{nonce}\n{body_hash}",
tag = AUTH_VERSION_TAG,
method = "GET",
path = "/v1/uploads/canary000000000",
ts = timestamp_ms,
nonce = nonce_b58,
body_hash = EMPTY_BODY_HASH,
);
let pubkey_bytes_vec = bs58::decode(&pubkey_b58)
.into_vec()
.expect("pubkey header is base58");
let pubkey_bytes: [u8; 32] = pubkey_bytes_vec.try_into().expect("32-byte pubkey");
let verifying_key = VerifyingKey::from_bytes(&pubkey_bytes).expect("valid ed25519 point");
let sig_bytes_vec = bs58::decode(&signature_b58)
.into_vec()
.expect("signature header is base58");
let sig_bytes: [u8; 64] = sig_bytes_vec.try_into().expect("64-byte signature");
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify(canonical.as_bytes(), &signature)
.expect("signature must verify against the canonical message");
}