diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 91197d7ec..e1190d732 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -676,6 +676,10 @@ async fn test_cors_origins() { } } +// --- Plan query filter tests --- + +#[tokio::test] +async fn test_get_plans_filter_by_beneficiary_only() { #[tokio::test] async fn test_calculate_yield_with_rate() { let app = setup_app(); @@ -683,12 +687,18 @@ async fn test_calculate_yield_with_rate() { .oneshot( Request::builder() .method(http::Method::GET) + .uri("/api/plans?beneficiary=GBENEF123") .uri("/api/yield/calculate?amount=10000&yield_rate_bps=500&elapsed_secs=31557600") .body(Body::empty()) .unwrap(), ) .await .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_get_plans_filter_by_both_owner_and_beneficiary() { assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) @@ -712,12 +722,18 @@ async fn test_calculate_yield_default_rate() { .oneshot( Request::builder() .method(http::Method::GET) + .uri("/api/plans?owner=GOWNER123&beneficiary=GBENEF123") .uri("/api/yield/calculate?amount=2000&elapsed_secs=31557600") .body(Body::empty()) .unwrap(), ) .await .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_get_plans_all_no_filters() { assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) @@ -735,12 +751,41 @@ async fn test_calculate_yield_zero_elapsed() { .oneshot( Request::builder() .method(http::Method::GET) + .uri("/api/plans") .uri("/api/yield/calculate?amount=5000&yield_rate_bps=1000&elapsed_secs=0") .body(Body::empty()) .unwrap(), ) .await .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_get_plans_owner_filter_caches_on_miss() { + let cache = PlanCache::memory(); + let query = inheritx_backend::api::PlanQuery { + owner: Some("GOWNER123".to_string()), + beneficiary: None, + }; + let cached_plans = vec![PlanResponse { + id: uuid::Uuid::new_v4(), + owner_address: "GOWNER123".to_string(), + token_address: "USDC".to_string(), + amount: rust_decimal::Decimal::from(1000), + grace_period: 3600, + grace_period_seconds: 3600, + earn_yield: true, + last_ping: 1_718_000_000, + is_active: true, + status: "ACTIVE".to_string(), + yield_rate_bps: 500, + accrued_yield: 25.5, + created_at: chrono::Utc::now(), + beneficiaries: vec![], + }]; + cache.set_plans(&query, &cached_plans).await.unwrap(); + let app = setup_app_with_cache(cache); assert_eq!(response.status(), StatusCode::OK); let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) @@ -757,12 +802,20 @@ async fn test_calculate_yield_invalid_amount() { .oneshot( Request::builder() .method(http::Method::GET) + .uri("/api/plans?owner=GOWNER123") .uri("/api/yield/calculate?amount=-100&yield_rate_bps=500&elapsed_secs=1000") .body(Body::empty()) .unwrap(), ) .await .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); + assert_eq!(body[0]["owner_address"], "GOWNER123"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } diff --git a/backend/tests/jwt_auth_tests.rs b/backend/tests/jwt_auth_tests.rs new file mode 100644 index 000000000..957cf3b8b --- /dev/null +++ b/backend/tests/jwt_auth_tests.rs @@ -0,0 +1,212 @@ +use axum::{ + body::Body, + http::{self, Request, StatusCode}, +}; +use inheritx_backend::auth::Claims; +use jsonwebtoken::{encode, EncodingKey, Header}; +use std::time::Duration; +use tower::ServiceExt; + +const JWT_SECRET: &str = "test-jwt-secret-for-testing"; + +fn ensure_jwt_secret() { + std::env::set_var("JWT_SECRET", JWT_SECRET); +} + +fn setup_app() -> axum::Router { + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/test".to_string()); + let db_pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(1)) + .connect_lazy(&database_url) + .unwrap(); + let state = std::sync::Arc::new(inheritx_backend::AppState { + anchor: std::sync::Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + db_pool, + kyc_tx: tokio::sync::broadcast::channel(16).0, + kyc_webhook_secret: None, + apy_config: inheritx_backend::yield_calculator::ApyConfig::default(), + plan_cache: inheritx_backend::PlanCache::disabled(), + apy_cache: dashmap::DashMap::new(), + stellar_submit: inheritx_backend::stellar_submit::StellarSubmitClient::new( + "https://horizon-testnet.stellar.org".to_string(), + ), + }); + inheritx_backend::create_router(state) +} + +fn plan_report_uri() -> String { + format!("/api/plans/{}/report", uuid::Uuid::nil()) +} + +fn generate_token(role: &str, secret: &str) -> String { + let claims = Claims { + sub: "test-admin-id".to_string(), + role: role.to_string(), + exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize, + }; + encode( + &Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + ) + .unwrap() +} + +#[tokio::test] +async fn test_jwt_missing_authorization_header() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_invalid_header_format() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", "NotBearer token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_empty_bearer_token() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", "Bearer ") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_invalid_token_payload() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", "Bearer invalid.jwt.token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_valid_token_with_non_admin_role() { + ensure_jwt_secret(); + let token = generate_token("user", JWT_SECRET); + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_valid_admin_token_passes_middleware() { + ensure_jwt_secret(); + let token = generate_token("admin", JWT_SECRET); + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + assert_ne!( + status, + StatusCode::UNAUTHORIZED, + "JWT middleware should have passed for valid admin token" + ); +} + +#[tokio::test] +async fn test_jwt_token_signed_with_wrong_secret_rejected() { + ensure_jwt_secret(); + let token = generate_token("admin", "some-other-secret"); + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_jwt_expired_token_rejected() { + ensure_jwt_secret(); + let claims = Claims { + sub: "test-admin-id".to_string(), + role: "admin".to_string(), + exp: (chrono::Utc::now() - chrono::Duration::hours(1)).timestamp() as usize, + }; + let token = encode( + &Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &EncodingKey::from_secret(JWT_SECRET.as_ref()), + ) + .unwrap(); + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri(&plan_report_uri()) + .header("Authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/backend/tests/kyc_api_tests.rs b/backend/tests/kyc_api_tests.rs new file mode 100644 index 000000000..fb7a34bcd --- /dev/null +++ b/backend/tests/kyc_api_tests.rs @@ -0,0 +1,257 @@ +use axum::{ + body::Body, + http::{self, Request, StatusCode}, +}; +use inheritx_backend::{create_router, AppState, PlanCache}; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; +use tower::ServiceExt; + +fn setup_app() -> axum::Router { + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/test".to_string()); + let db_pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(1)) + .connect_lazy(&database_url) + .unwrap(); + let state = Arc::new(AppState { + anchor: Arc::new(inheritx_backend::stellar_anchor::AnchorRegistry::new()), + db_pool, + kyc_tx: tokio::sync::broadcast::channel(16).0, + kyc_webhook_secret: None, + apy_config: inheritx_backend::yield_calculator::ApyConfig::default(), + plan_cache: PlanCache::disabled(), + apy_cache: dashmap::DashMap::new(), + stellar_submit: inheritx_backend::stellar_submit::StellarSubmitClient::new( + "https://horizon-testnet.stellar.org".to_string(), + ), + }); + create_router(state) +} + +#[tokio::test] +async fn test_get_kyc_status_requires_wallet_address() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_get_kyc_status_with_address_hits_db() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/status?wallet_address=GDTEST123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_submit_kyc_rejects_empty_body() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/kyc/submit") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from("")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_submit_kyc_with_valid_body_hits_db() { + let app = setup_app(); + let body = json!({ + "wallet_address": "GDTEST123", + "full_name": "John Doe", + "email": "john@example.com", + "date_of_birth": "1990-01-01", + "nationality": "US", + "id_type": "international_passport", + "id_number": "AB123456", + "expiry_date": "2030-01-01", + "street_address": "123 Main St", + "city": "New York", + "country": "US", + "postal_code": "10001" + }) + .to_string(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/kyc/submit") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[tokio::test] +async fn test_upload_kyc_document_returns_ok() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/kyc/upload") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_upload_kyc_document_returns_expected_structure() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/api/kyc/upload") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.get("document_id").is_some(), "missing 'document_id'"); + assert!(body.get("url").is_some(), "missing 'url'"); +} + +#[tokio::test] +async fn test_is_kyc_required_returns_true() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/required") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["required"], true); + assert!(body.get("reason").is_some()); +} + +#[tokio::test] +async fn test_get_kyc_requirements_returns_ok() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/requirements") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_get_kyc_requirements_returns_expected_structure() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/requirements") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.get("requires_id").is_some()); + assert!(body.get("requires_address_proof").is_some()); + assert!(body.get("supported_id_types").is_some()); + assert!(body.get("supported_countries").is_some()); + let id_types = body["supported_id_types"].as_array().unwrap(); + assert!(!id_types.is_empty()); + let countries = body["supported_countries"].as_array().unwrap(); + assert!(!countries.is_empty()); +} + +#[tokio::test] +async fn test_get_kyc_status_is_public() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/kyc/status?wallet_address=GDTEST123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_kyc_endpoints_do_not_require_auth() { + let app = setup_app(); + for (method, uri) in [ + (http::Method::GET, "/api/kyc/status?wallet_address=GDTEST"), + (http::Method::POST, "/api/kyc/submit"), + (http::Method::POST, "/api/kyc/upload"), + (http::Method::GET, "/api/kyc/required"), + (http::Method::GET, "/api/kyc/requirements"), + ] { + let req = Request::builder() + .method(method) + .uri(uri) + .header(http::header::CONTENT_TYPE, "application/json"); + let response = app + .clone() + .oneshot(req.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_ne!( + response.status(), + StatusCode::UNAUTHORIZED, + "Endpoint {uri} should not require auth" + ); + } +}