diff --git a/migrations/20270527000000_sandbox_environment.sql b/migrations/20270527000000_sandbox_environment.sql new file mode 100644 index 0000000..3b5e1f4 --- /dev/null +++ b/migrations/20270527000000_sandbox_environment.sql @@ -0,0 +1,117 @@ +-- Sandbox Environment: Data Factory, Chaos Injection, Certification Suite +-- Issue #348-sandbox + +-- ── Sandbox test users (generated by Data Factory) ─────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_test_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES developer_applications(id) ON DELETE CASCADE, + external_id TEXT NOT NULL, + full_name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + kyc_status TEXT NOT NULL DEFAULT 'verified' + CHECK (kyc_status IN ('unverified', 'pending', 'verified', 'rejected')), + balance_ngn NUMERIC(20, 4) NOT NULL DEFAULT 0, + balance_cngn NUMERIC(20, 7) NOT NULL DEFAULT 0, + stellar_address TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_sandbox_test_users_app ON sandbox_test_users(application_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_sandbox_test_users_app_ext + ON sandbox_test_users(application_id, external_id); + +-- ── Sandbox test bank accounts ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_test_bank_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES developer_applications(id) ON DELETE CASCADE, + test_user_id UUID REFERENCES sandbox_test_users(id) ON DELETE CASCADE, + account_number TEXT NOT NULL, + bank_code TEXT NOT NULL, + bank_name TEXT NOT NULL, + account_name TEXT NOT NULL, + currency TEXT NOT NULL DEFAULT 'NGN', + is_verified BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_sandbox_bank_accounts_app ON sandbox_test_bank_accounts(application_id); + +-- ── Sandbox mock transactions ───────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_mock_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES developer_applications(id) ON DELETE CASCADE, + test_user_id UUID REFERENCES sandbox_test_users(id) ON DELETE SET NULL, + transaction_type TEXT NOT NULL CHECK (transaction_type IN ('onramp', 'offramp', 'transfer', 'mint', 'burn')), + status TEXT NOT NULL DEFAULT 'completed' + CHECK (status IN ('pending', 'completed', 'failed', 'rejected')), + amount NUMERIC(20, 4) NOT NULL, + currency TEXT NOT NULL, + stellar_tx_hash TEXT, + reference TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_sandbox_mock_txns_app ON sandbox_mock_transactions(application_id); + +-- ── Chaos scenarios ─────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_chaos_scenarios ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES developer_applications(id) ON DELETE CASCADE, + scenario_type TEXT NOT NULL + CHECK (scenario_type IN ( + 'http_500', 'http_429', 'tx_rejected', + 'latency_ms', 'network_timeout' + )), + -- For latency_ms: the delay in milliseconds + -- For http_429: optional retry_after header value + config JSONB NOT NULL DEFAULT '{}', + -- Which path prefix to intercept, e.g. "/api/v1/onramp" + target_path_prefix TEXT NOT NULL DEFAULT '/', + is_active BOOLEAN NOT NULL DEFAULT FALSE, + activated_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_chaos_scenarios_app_active + ON sandbox_chaos_scenarios(application_id, is_active); + +-- ── Certification runs ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_certification_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + application_id UUID NOT NULL REFERENCES developer_applications(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'running' + CHECK (status IN ('running', 'passed', 'failed')), + score SMALLINT, -- 0-100 + passed_tests SMALLINT NOT NULL DEFAULT 0, + total_tests SMALLINT NOT NULL DEFAULT 0, + production_gate_met BOOLEAN NOT NULL DEFAULT FALSE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_cert_runs_app ON sandbox_certification_runs(application_id); + +-- ── Certification test results ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS sandbox_certification_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES sandbox_certification_runs(id) ON DELETE CASCADE, + test_name TEXT NOT NULL, + category TEXT NOT NULL + CHECK (category IN ('deposit', 'withdrawal', 'balance', 'webhook', 'oauth', 'error_handling')), + passed BOOLEAN NOT NULL, + error_message TEXT, + duration_ms INTEGER, + executed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_cert_results_run ON sandbox_certification_results(run_id); diff --git a/src/developer_portal/certification_service.rs b/src/developer_portal/certification_service.rs new file mode 100644 index 0000000..7d38c85 --- /dev/null +++ b/src/developer_portal/certification_service.rs @@ -0,0 +1,296 @@ +use super::models::*; +use crate::database::PgPool; +use chrono::Utc; +use std::sync::Arc; +use uuid::Uuid; + +/// Minimum score (0-100) required to unlock production access. +const PRODUCTION_GATE_SCORE: i16 = 80; + +/// A single certification test definition. +struct CertTest { + name: &'static str, + category: &'static str, + /// Returns (passed, error_message) + run: fn(&CertContext) -> (bool, Option), +} + +/// Lightweight context passed to each test function. +struct CertContext { + has_test_users: bool, + has_bank_accounts: bool, + has_completed_onramp: bool, + has_completed_offramp: bool, + has_stellar_address: bool, + has_webhook_config: bool, + has_oauth_client: bool, + has_api_key: bool, + has_error_handling_test: bool, +} + +static CERT_TESTS: &[CertTest] = &[ + CertTest { + name: "test_data_generated", + category: "deposit", + run: |ctx| (ctx.has_test_users, if !ctx.has_test_users { Some("No test users found. Call POST /sandbox/data/generate first.".into()) } else { None }), + }, + CertTest { + name: "bank_account_linked", + category: "deposit", + run: |ctx| (ctx.has_bank_accounts, if !ctx.has_bank_accounts { Some("No test bank accounts found.".into()) } else { None }), + }, + CertTest { + name: "successful_deposit", + category: "deposit", + run: |ctx| (ctx.has_completed_onramp, if !ctx.has_completed_onramp { Some("No completed onramp transaction found.".into()) } else { None }), + }, + CertTest { + name: "successful_withdrawal", + category: "withdrawal", + run: |ctx| (ctx.has_completed_offramp, if !ctx.has_completed_offramp { Some("No completed offramp transaction found.".into()) } else { None }), + }, + CertTest { + name: "balance_inquiry", + category: "balance", + run: |ctx| (ctx.has_test_users, if !ctx.has_test_users { Some("No test users to query balance for.".into()) } else { None }), + }, + CertTest { + name: "stellar_testnet_address", + category: "balance", + run: |ctx| (ctx.has_stellar_address, if !ctx.has_stellar_address { Some("No Stellar testnet address provisioned.".into()) } else { None }), + }, + CertTest { + name: "webhook_endpoint_configured", + category: "webhook", + run: |ctx| (ctx.has_webhook_config, if !ctx.has_webhook_config { Some("No webhook endpoint configured.".into()) } else { None }), + }, + CertTest { + name: "oauth2_client_registered", + category: "oauth", + run: |ctx| (ctx.has_oauth_client, if !ctx.has_oauth_client { Some("No OAuth2 client registered.".into()) } else { None }), + }, + CertTest { + name: "api_key_provisioned", + category: "oauth", + run: |ctx| (ctx.has_api_key, if !ctx.has_api_key { Some("No API key provisioned.".into()) } else { None }), + }, + CertTest { + name: "error_handling_scenario_tested", + category: "error_handling", + run: |ctx| (ctx.has_error_handling_test, if !ctx.has_error_handling_test { Some("No chaos scenario has been activated. Test your error handling.".into()) } else { None }), + }, +]; + +#[derive(Clone)] +pub struct CertificationService { + pool: Arc, +} + +impl CertificationService { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub async fn run_certification( + &self, + application_id: Uuid, + ) -> Result { + // Gather context from DB + let ctx = self.build_context(application_id).await?; + + // Create run record + let run = sqlx::query_as!( + SandboxCertificationRun, + r#"INSERT INTO sandbox_certification_runs + (application_id, status, passed_tests, total_tests) + VALUES ($1, 'running', 0, $2) + RETURNING *"#, + application_id, + CERT_TESTS.len() as i16, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + // Execute each test + let mut results = Vec::with_capacity(CERT_TESTS.len()); + let mut passed = 0i16; + + for test in CERT_TESTS { + let start = std::time::Instant::now(); + let (test_passed, error_message) = (test.run)(&ctx); + let duration_ms = start.elapsed().as_millis() as i32; + + if test_passed { + passed += 1; + } + + let result = sqlx::query_as!( + SandboxCertificationResult, + r#"INSERT INTO sandbox_certification_results + (run_id, test_name, category, passed, error_message, duration_ms) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *"#, + run.id, + test.name, + test.category, + test_passed, + error_message, + duration_ms, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + results.push(result); + } + + let total = CERT_TESTS.len() as i16; + let score = (passed * 100 / total) as i16; + let gate_met = score >= PRODUCTION_GATE_SCORE; + let status = if gate_met { "passed" } else { "failed" }; + + let run = sqlx::query_as!( + SandboxCertificationRun, + r#"UPDATE sandbox_certification_runs + SET status = $1, score = $2, passed_tests = $3, + production_gate_met = $4, completed_at = now() + WHERE id = $5 + RETURNING *"#, + status, + score, + passed, + gate_met, + run.id, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + Ok(CertificationRunSummary { run, results }) + } + + pub async fn latest_run( + &self, + application_id: Uuid, + ) -> Result, DeveloperPortalError> { + let run = sqlx::query_as!( + SandboxCertificationRun, + "SELECT * FROM sandbox_certification_runs WHERE application_id = $1 ORDER BY started_at DESC LIMIT 1", + application_id + ) + .fetch_optional(self.pool.as_ref()) + .await?; + + let Some(run) = run else { return Ok(None) }; + + let results = sqlx::query_as!( + SandboxCertificationResult, + "SELECT * FROM sandbox_certification_results WHERE run_id = $1 ORDER BY executed_at", + run.id + ) + .fetch_all(self.pool.as_ref()) + .await?; + + Ok(Some(CertificationRunSummary { run, results })) + } + + /// Returns true if the application has passed certification and may request production access. + pub async fn production_gate_met(&self, application_id: Uuid) -> Result { + let row = sqlx::query!( + r#"SELECT production_gate_met FROM sandbox_certification_runs + WHERE application_id = $1 + ORDER BY started_at DESC LIMIT 1"#, + application_id + ) + .fetch_optional(self.pool.as_ref()) + .await?; + + Ok(row.map(|r| r.production_gate_met).unwrap_or(false)) + } + + // ── Context builder ─────────────────────────────────────────────────────── + + async fn build_context(&self, application_id: Uuid) -> Result { + let user_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_test_users WHERE application_id = $1", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let bank_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_test_bank_accounts WHERE application_id = $1", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let onramp_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_mock_transactions WHERE application_id = $1 AND transaction_type = 'onramp' AND status = 'completed'", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let offramp_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_mock_transactions WHERE application_id = $1 AND transaction_type = 'offramp' AND status = 'completed'", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let stellar_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_test_users WHERE application_id = $1 AND stellar_address IS NOT NULL", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let webhook_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM webhook_configurations WHERE application_id = $1 AND status = 'active'", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let oauth_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM oauth_clients WHERE application_id = $1 AND environment = 'sandbox' AND status = 'active'", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let api_key_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM api_keys WHERE application_id = $1 AND environment = 'sandbox' AND status = 'active'", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + let chaos_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) FROM sandbox_chaos_scenarios WHERE application_id = $1 AND activated_at IS NOT NULL", + application_id + ) + .fetch_one(self.pool.as_ref()) + .await? + .unwrap_or(0); + + Ok(CertContext { + has_test_users: user_count > 0, + has_bank_accounts: bank_count > 0, + has_completed_onramp: onramp_count > 0, + has_completed_offramp: offramp_count > 0, + has_stellar_address: stellar_count > 0, + has_webhook_config: webhook_count > 0, + has_oauth_client: oauth_count > 0, + has_api_key: api_key_count > 0, + has_error_handling_test: chaos_count > 0, + }) + } +} diff --git a/src/developer_portal/chaos_service.rs b/src/developer_portal/chaos_service.rs new file mode 100644 index 0000000..bb82b4e --- /dev/null +++ b/src/developer_portal/chaos_service.rs @@ -0,0 +1,144 @@ +use super::models::*; +use crate::database::PgPool; +use chrono::Utc; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct ChaosInjectionService { + pool: Arc, +} + +impl ChaosInjectionService { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub async fn create_scenario( + &self, + application_id: Uuid, + req: CreateChaosScenarioRequest, + ) -> Result { + let valid_types = ["http_500", "http_429", "tx_rejected", "latency_ms", "network_timeout"]; + if !valid_types.contains(&req.scenario_type.as_str()) { + return Err(DeveloperPortalError::InvalidStatus); + } + + let config = req.config.unwrap_or_else(|| self.default_config(&req.scenario_type)); + let target = req.target_path_prefix.unwrap_or_else(|| "/".to_string()); + let expires_at = req + .expires_in_secs + .map(|s| Utc::now() + chrono::Duration::seconds(s as i64)); + + let scenario = sqlx::query_as!( + SandboxChaosScenario, + r#"INSERT INTO sandbox_chaos_scenarios + (application_id, scenario_type, config, target_path_prefix, is_active, expires_at) + VALUES ($1, $2, $3, $4, false, $5) + RETURNING *"#, + application_id, + req.scenario_type, + config, + target, + expires_at, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + Ok(scenario) + } + + pub async fn set_active( + &self, + application_id: Uuid, + scenario_id: Uuid, + active: bool, + ) -> Result { + let activated_at = if active { Some(Utc::now()) } else { None }; + + let scenario = sqlx::query_as!( + SandboxChaosScenario, + r#"UPDATE sandbox_chaos_scenarios + SET is_active = $1, activated_at = $2 + WHERE id = $3 AND application_id = $4 + RETURNING *"#, + active, + activated_at, + scenario_id, + application_id, + ) + .fetch_optional(self.pool.as_ref()) + .await? + .ok_or(DeveloperPortalError::ApplicationNotFound)?; + + Ok(scenario) + } + + pub async fn list_scenarios( + &self, + application_id: Uuid, + ) -> Result, DeveloperPortalError> { + let scenarios = sqlx::query_as!( + SandboxChaosScenario, + "SELECT * FROM sandbox_chaos_scenarios WHERE application_id = $1 ORDER BY created_at DESC", + application_id + ) + .fetch_all(self.pool.as_ref()) + .await?; + Ok(scenarios) + } + + pub async fn delete_scenario( + &self, + application_id: Uuid, + scenario_id: Uuid, + ) -> Result<(), DeveloperPortalError> { + let rows = sqlx::query!( + "DELETE FROM sandbox_chaos_scenarios WHERE id = $1 AND application_id = $2", + scenario_id, + application_id + ) + .execute(self.pool.as_ref()) + .await? + .rows_affected(); + + if rows == 0 { + return Err(DeveloperPortalError::ApplicationNotFound); + } + Ok(()) + } + + /// Returns the active scenario for a given path, if any (and not expired). + pub async fn active_scenario_for_path( + &self, + application_id: Uuid, + path: &str, + ) -> Result, DeveloperPortalError> { + let scenario = sqlx::query_as!( + SandboxChaosScenario, + r#"SELECT * FROM sandbox_chaos_scenarios + WHERE application_id = $1 + AND is_active = true + AND (expires_at IS NULL OR expires_at > now()) + AND $2 LIKE (target_path_prefix || '%') + ORDER BY created_at DESC + LIMIT 1"#, + application_id, + path, + ) + .fetch_optional(self.pool.as_ref()) + .await?; + + Ok(scenario) + } + + fn default_config(&self, scenario_type: &str) -> serde_json::Value { + match scenario_type { + "latency_ms" => json!({"delay_ms": 2000}), + "http_429" => json!({"retry_after": 60}), + "tx_rejected" => json!({"reason": "INSUFFICIENT_FUNDS"}), + _ => json!({}), + } + } +} diff --git a/src/developer_portal/data_factory.rs b/src/developer_portal/data_factory.rs new file mode 100644 index 0000000..58d891a --- /dev/null +++ b/src/developer_portal/data_factory.rs @@ -0,0 +1,244 @@ +use super::models::*; +use crate::database::PgPool; +use rand::Rng; +use rust_decimal::Decimal; +use std::sync::Arc; +use uuid::Uuid; + +static BANK_NAMES: &[(&str, &str)] = &[ + ("044", "Access Bank"), + ("023", "Citibank"), + ("050", "EcoBank"), + ("011", "First Bank"), + ("214", "First City Monument Bank"), + ("070", "Fidelity Bank"), + ("058", "GTBank"), + ("030", "Heritage Bank"), + ("301", "Jaiz Bank"), + ("082", "Keystone Bank"), + ("076", "Polaris Bank"), + ("221", "Stanbic IBTC"), + ("068", "Standard Chartered"), + ("232", "Sterling Bank"), + ("032", "Union Bank"), + ("033", "United Bank for Africa"), + ("215", "Unity Bank"), + ("035", "Wema Bank"), + ("057", "Zenith Bank"), +]; + +#[derive(Clone)] +pub struct DataFactoryService { + pool: Arc, +} + +impl DataFactoryService { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + pub async fn generate_test_data( + &self, + application_id: Uuid, + req: GenerateTestDataRequest, + ) -> Result { + let user_count = req.user_count.unwrap_or(3).min(50).max(1) as usize; + let txns_per_user = req.transactions_per_user.unwrap_or(5).min(20).max(1) as usize; + let initial_balance = req + .initial_balance_ngn + .unwrap_or_else(|| Decimal::new(100_000_00, 2)); // ₦100,000 + + let mut users = Vec::with_capacity(user_count); + let mut bank_accounts_created = 0usize; + let mut transactions_created = 0usize; + + for i in 0..user_count { + let user = self + .create_test_user(application_id, i, initial_balance) + .await?; + + // One bank account per user + self.create_test_bank_account(application_id, user.id).await?; + bank_accounts_created += 1; + + // Mock transactions + for j in 0..txns_per_user { + self.create_mock_transaction(application_id, user.id, j) + .await?; + transactions_created += 1; + } + + users.push(user); + } + + Ok(GenerateTestDataResponse { + users_created: user_count, + bank_accounts_created, + transactions_created, + users, + }) + } + + pub async fn reset_environment( + &self, + application_id: Uuid, + ) -> Result<(), DeveloperPortalError> { + // Cascade deletes handle bank accounts and transactions via FK + sqlx::query!( + "DELETE FROM sandbox_test_users WHERE application_id = $1", + application_id + ) + .execute(self.pool.as_ref()) + .await?; + + sqlx::query!( + "DELETE FROM sandbox_chaos_scenarios WHERE application_id = $1", + application_id + ) + .execute(self.pool.as_ref()) + .await?; + + Ok(()) + } + + pub async fn list_test_users( + &self, + application_id: Uuid, + ) -> Result, DeveloperPortalError> { + let users = sqlx::query_as!( + SandboxTestUser, + "SELECT * FROM sandbox_test_users WHERE application_id = $1 ORDER BY created_at", + application_id + ) + .fetch_all(self.pool.as_ref()) + .await?; + Ok(users) + } + + pub async fn list_mock_transactions( + &self, + application_id: Uuid, + ) -> Result, DeveloperPortalError> { + let txns = sqlx::query_as!( + SandboxMockTransaction, + "SELECT * FROM sandbox_mock_transactions WHERE application_id = $1 ORDER BY created_at DESC", + application_id + ) + .fetch_all(self.pool.as_ref()) + .await?; + Ok(txns) + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + async fn create_test_user( + &self, + application_id: Uuid, + index: usize, + balance_ngn: Decimal, + ) -> Result { + let mut rng = rand::thread_rng(); + let first_names = ["Amara", "Chidi", "Fatima", "Emeka", "Ngozi", "Tunde", "Aisha", "Bola"]; + let last_names = ["Okafor", "Adeyemi", "Ibrahim", "Nwosu", "Bello", "Eze", "Abubakar", "Osei"]; + + let first = first_names[rng.gen_range(0..first_names.len())]; + let last = last_names[rng.gen_range(0..last_names.len())]; + let full_name = format!("{} {}", first, last); + let external_id = format!("test_user_{}", Uuid::new_v4().simple()); + let email = format!("{}_{}.{}@sandbox.aframp.io", first.to_lowercase(), index, last.to_lowercase()); + let stellar_address = self.generate_stellar_testnet_address(); + + let user = sqlx::query_as!( + SandboxTestUser, + r#"INSERT INTO sandbox_test_users + (application_id, external_id, full_name, email, phone, kyc_status, + balance_ngn, balance_cngn, stellar_address, metadata) + VALUES ($1, $2, $3, $4, $5, 'verified', $6, 0, $7, '{}') + RETURNING *"#, + application_id, + external_id, + full_name, + email, + format!("+234{}", rng.gen_range(7000000000u64..9999999999u64)), + balance_ngn, + stellar_address, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + Ok(user) + } + + async fn create_test_bank_account( + &self, + application_id: Uuid, + test_user_id: Uuid, + ) -> Result { + let mut rng = rand::thread_rng(); + let (bank_code, bank_name) = BANK_NAMES[rng.gen_range(0..BANK_NAMES.len())]; + let account_number: String = (0..10).map(|_| rng.gen_range(0..10).to_string()).collect(); + + let account = sqlx::query_as!( + SandboxTestBankAccount, + r#"INSERT INTO sandbox_test_bank_accounts + (application_id, test_user_id, account_number, bank_code, bank_name, account_name, currency, is_verified) + VALUES ($1, $2, $3, $4, $5, 'Sandbox Test Account', 'NGN', true) + RETURNING *"#, + application_id, + test_user_id, + account_number, + bank_code, + bank_name, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + Ok(account) + } + + async fn create_mock_transaction( + &self, + application_id: Uuid, + test_user_id: Uuid, + index: usize, + ) -> Result { + let mut rng = rand::thread_rng(); + let types = ["onramp", "offramp", "transfer"]; + let statuses = ["completed", "completed", "completed", "failed"]; // 75% success + let tx_type = types[index % types.len()]; + let status = statuses[rng.gen_range(0..statuses.len())]; + let amount = Decimal::new(rng.gen_range(1_000_00..500_000_00), 2); + let reference = format!("SANDBOX_{}", Uuid::new_v4().simple().to_string().to_uppercase()); + let stellar_hash = format!("{:064x}", rng.gen::() as u128 * rng.gen::() as u128); + + let txn = sqlx::query_as!( + SandboxMockTransaction, + r#"INSERT INTO sandbox_mock_transactions + (application_id, test_user_id, transaction_type, status, amount, currency, + stellar_tx_hash, reference, metadata) + VALUES ($1, $2, $3, $4, $5, 'NGN', $6, $7, '{}') + RETURNING *"#, + application_id, + test_user_id, + tx_type, + status, + amount, + stellar_hash, + reference, + ) + .fetch_one(self.pool.as_ref()) + .await?; + + Ok(txn) + } + + fn generate_stellar_testnet_address(&self) -> String { + // Stellar public keys start with 'G' and are 56 chars (base32) + let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut rng = rand::thread_rng(); + let body: String = (0..55) + .map(|_| alphabet[rng.gen_range(0..alphabet.len())] as char) + .collect(); + format!("G{}", body) + } +} diff --git a/src/developer_portal/models.rs b/src/developer_portal/models.rs index 2e1a3d1..d0cf21f 100644 --- a/src/developer_portal/models.rs +++ b/src/developer_portal/models.rs @@ -418,3 +418,134 @@ pub enum DeveloperPortalError { #[error("Crypto error: {0}")] Crypto(String), } + +// ── Sandbox Data Factory ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxTestUser { + pub id: Uuid, + pub application_id: Uuid, + pub external_id: String, + pub full_name: String, + pub email: String, + pub phone: Option, + pub kyc_status: String, + pub balance_ngn: rust_decimal::Decimal, + pub balance_cngn: rust_decimal::Decimal, + pub stellar_address: Option, + pub metadata: serde_json::Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxTestBankAccount { + pub id: Uuid, + pub application_id: Uuid, + pub test_user_id: Option, + pub account_number: String, + pub bank_code: String, + pub bank_name: String, + pub account_name: String, + pub currency: String, + pub is_verified: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxMockTransaction { + pub id: Uuid, + pub application_id: Uuid, + pub test_user_id: Option, + pub transaction_type: String, + pub status: String, + pub amount: rust_decimal::Decimal, + pub currency: String, + pub stellar_tx_hash: Option, + pub reference: String, + pub metadata: serde_json::Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateTestDataRequest { + /// Number of test users to create (1-50) + pub user_count: Option, + /// Number of mock transactions per user (1-20) + pub transactions_per_user: Option, + /// Initial NGN balance for each test user + pub initial_balance_ngn: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateTestDataResponse { + pub users_created: usize, + pub bank_accounts_created: usize, + pub transactions_created: usize, + pub users: Vec, +} + +// ── Chaos Injection ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxChaosScenario { + pub id: Uuid, + pub application_id: Uuid, + pub scenario_type: String, + pub config: serde_json::Value, + pub target_path_prefix: String, + pub is_active: bool, + pub activated_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateChaosScenarioRequest { + /// One of: http_500, http_429, tx_rejected, latency_ms, network_timeout + pub scenario_type: String, + /// For latency_ms: {"delay_ms": 2000} + /// For http_429: {"retry_after": 60} + pub config: Option, + /// Path prefix to intercept, e.g. "/api/v1/onramp" + pub target_path_prefix: Option, + /// Auto-expire after N seconds (optional) + pub expires_in_secs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivateChaosScenarioRequest { + pub active: bool, +} + +// ── Certification Suite ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxCertificationRun { + pub id: Uuid, + pub application_id: Uuid, + pub status: String, + pub score: Option, + pub passed_tests: i16, + pub total_tests: i16, + pub production_gate_met: bool, + pub started_at: DateTime, + pub completed_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct SandboxCertificationResult { + pub id: Uuid, + pub run_id: Uuid, + pub test_name: String, + pub category: String, + pub passed: bool, + pub error_message: Option, + pub duration_ms: Option, + pub executed_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CertificationRunSummary { + pub run: SandboxCertificationRun, + pub results: Vec, +}