From 2d55fadc02ebacfb43c8e0a541a4737f19da0d6f Mon Sep 17 00:00:00 2001 From: Olowodarey Date: Tue, 24 Feb 2026 07:11:39 +0100 Subject: [PATCH 1/2] feat: Implement and test POST /api/kyc/submit endpoint --- backend/tests/kyc_submit_tests.rs | 241 ++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 backend/tests/kyc_submit_tests.rs diff --git a/backend/tests/kyc_submit_tests.rs b/backend/tests/kyc_submit_tests.rs new file mode 100644 index 000000000..42d60bf7e --- /dev/null +++ b/backend/tests/kyc_submit_tests.rs @@ -0,0 +1,241 @@ + +mod helpers; + +use axum::{ + body::Body, + http::{header, Request, StatusCode}, +}; +use inheritx_backend::auth::UserClaims; +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde_json::Value; +use tower::ServiceExt; +use uuid::Uuid; + +/// The secret must match what TestContext injects into the Config. +/// helpers::TestContext defaults to "test-jwt-secret" when JWT_SECRET is not set. +const JWT_SECRET: &[u8] = b"test-jwt-secret"; + +fn user_token(user_id: Uuid) -> String { + let claims = UserClaims { + user_id, + email: format!("user-{}@example.com", user_id), + // Use a far-future timestamp so the token never expires in tests + exp: 9_999_999_999, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(JWT_SECRET), + ) + .expect("token encoding failed") +} + +// --------------------------------------------------------------------------- +// Test 1 – Authenticated user can submit KYC and receives a pending record +// --------------------------------------------------------------------------- +#[tokio::test] +async fn submit_kyc_returns_pending_for_authenticated_user() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("kyc-submit-{}@example.com", user_id)) + .bind("hashed_password") + .execute(&ctx.pool) + .await + .expect("failed to seed user"); + + let token = user_token(user_id); + + let response = ctx + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .header(header::AUTHORIZATION, format!("Bearer {}", token)) + .header(header::CONTENT_TYPE, "application/json") + .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: Value = serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(body["user_id"], user_id.to_string()); + assert_eq!(body["status"], "pending"); +} + +// --------------------------------------------------------------------------- +// Test 2 – Unauthenticated request is rejected with 401 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn submit_kyc_without_token_returns_unauthorized() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let response = ctx + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// Test 3 – Malformed / invalid token returns 401 +// --------------------------------------------------------------------------- +#[tokio::test] +async fn submit_kyc_with_invalid_token_returns_unauthorized() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let response = ctx + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .header(header::AUTHORIZATION, "Bearer totally.invalid.token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// Test 4 – Submitting KYC a second time is idempotent (returns existing record) +// --------------------------------------------------------------------------- +#[tokio::test] +async fn submit_kyc_is_idempotent_for_same_user() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("kyc-idem-{}@example.com", user_id)) + .bind("hashed_password") + .execute(&ctx.pool) + .await + .expect("failed to seed user"); + + let token = user_token(user_id); + + // First submission + let _ = ctx + .app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .header(header::AUTHORIZATION, format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + // Second submission – must still return 200 with pending status + let response = ctx + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .header(header::AUTHORIZATION, format!("Bearer {}", token)) + .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: Value = serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(body["user_id"], user_id.to_string()); + assert_eq!(body["status"], "pending"); +} + +// --------------------------------------------------------------------------- +// Test 5 – Response body contains expected KYC fields +// --------------------------------------------------------------------------- +#[tokio::test] +async fn submit_kyc_response_contains_expected_fields() { + let Some(ctx) = helpers::TestContext::from_env().await else { + return; + }; + + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)") + .bind(user_id) + .bind(format!("kyc-fields-{}@example.com", user_id)) + .bind("hashed_password") + .execute(&ctx.pool) + .await + .expect("failed to seed user"); + + let token = user_token(user_id); + + let response = ctx + .app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/kyc/submit") + .header(header::AUTHORIZATION, format!("Bearer {}", token)) + .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: Value = serde_json::from_slice(&body_bytes).unwrap(); + + // All KycRecord fields must be present in the response + assert!(body.get("user_id").is_some(), "missing field: user_id"); + assert!(body.get("status").is_some(), "missing field: status"); + assert!(body.get("created_at").is_some(), "missing field: created_at"); + + // reviewed_by and reviewed_at should be null on a fresh submission + assert!( + body["reviewed_by"].is_null(), + "reviewed_by should be null on submit" + ); + assert!( + body["reviewed_at"].is_null(), + "reviewed_at should be null on submit" + ); +} From 8a279b8ae2592131f2d136024f416e350b783019 Mon Sep 17 00:00:00 2001 From: Olowodarey Date: Tue, 24 Feb 2026 07:15:12 +0100 Subject: [PATCH 2/2] fmt check --- backend/tests/kyc_submit_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/tests/kyc_submit_tests.rs b/backend/tests/kyc_submit_tests.rs index 42d60bf7e..beb6d82ae 100644 --- a/backend/tests/kyc_submit_tests.rs +++ b/backend/tests/kyc_submit_tests.rs @@ -1,4 +1,3 @@ - mod helpers; use axum::{ @@ -227,7 +226,10 @@ async fn submit_kyc_response_contains_expected_fields() { // All KycRecord fields must be present in the response assert!(body.get("user_id").is_some(), "missing field: user_id"); assert!(body.get("status").is_some(), "missing field: status"); - assert!(body.get("created_at").is_some(), "missing field: created_at"); + assert!( + body.get("created_at").is_some(), + "missing field: created_at" + ); // reviewed_by and reviewed_at should be null on a fresh submission assert!(