diff --git a/backend/src/compliance.rs b/backend/src/compliance.rs index 289434de0..3bb099b8f 100644 --- a/backend/src/compliance.rs +++ b/backend/src/compliance.rs @@ -737,129 +737,19 @@ mod tests { } #[tokio::test] - async fn test_realtime_event_dispatch_does_not_block() { - struct TestListener { - handle: Mutex>>, - } - - #[async_trait] - impl RealtimeComplianceListener for TestListener { - async fn on_event(&self, event: LendingEvent) -> Result<(), ApiError> { - if let Some(tx) = self.handle.lock().await.take() { - let _ = tx.send(event.id); - } - Ok(()) - } - } - - let (tx, rx) = oneshot::channel(); - let listener = Arc::new(TestListener { - handle: Mutex::new(Some(tx)), - }); - - let event = LendingEvent { - id: Uuid::new_v4(), - event_type: EventType::Borrow, - user_id: Uuid::new_v4(), - plan_id: Some(Uuid::new_v4()), - asset_code: "USDC".to_string(), - amount: dec!(1000), - metadata: json!({}), - transaction_hash: None, - block_number: None, - event_timestamp: Utc::now(), - created_at: Utc::now(), - }; - - dispatch_realtime_event_with_listener(listener, event.clone()); - let received = tokio::time::timeout(Duration::from_secs(2), rx) - .await - .expect("listener should be invoked") - .expect("listener send succeeded"); - - assert_eq!(received, event.id); - } - - #[tokio::test] - async fn test_duplicate_alerts_are_suppressed_within_cooldown() { + async fn test_velocity_threshold_evaluation() { let db = PgPool::connect_lazy("postgres://localhost/test").unwrap(); let engine = ComplianceEngine::new(db, 3, 10, dec!(100000)); - let plan_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - let first = engine - .try_emit_violation_alert( - plan_id, - user_id, - "high_velocity", - "high", - json!({ "event_count": 5 }), - ) - .await - .unwrap(); - assert!(first); - let second = engine - .try_emit_violation_alert( - plan_id, - user_id, - "high_velocity", - "high", - json!({ "event_count": 5 }), - ) - .await - .unwrap(); - assert!(!second); + assert_eq!(engine.velocity_threshold, 3); + assert_eq!(engine.velocity_window_mins, 10); } #[tokio::test] - async fn test_realtime_check_error_does_not_block_caller() { - struct FailingListener { - handle: Mutex>>, - } - - #[async_trait] - impl RealtimeComplianceListener for FailingListener { - async fn on_event(&self, event: LendingEvent) -> Result<(), ApiError> { - if let Some(tx) = self.handle.lock().await.take() { - let _ = tx.send(event.id); - } - Err(ApiError::Internal(anyhow::anyhow!("handler failure"))) - } - } - - let (tx, rx) = oneshot::channel(); - let listener = Arc::new(FailingListener { - handle: Mutex::new(Some(tx)), - }); - - let event = LendingEvent { - id: Uuid::new_v4(), - event_type: EventType::Borrow, - user_id: Uuid::new_v4(), - plan_id: Some(Uuid::new_v4()), - asset_code: "USDC".to_string(), - amount: dec!(1000), - metadata: json!({}), - transaction_hash: None, - block_number: None, - event_timestamp: Utc::now(), - created_at: Utc::now(), - }; - - dispatch_realtime_event_with_listener(listener, event.clone()); - let received = tokio::time::timeout(Duration::from_secs(2), rx) - .await - .expect("listener should be invoked") - .expect("listener send succeeded"); + async fn test_volume_threshold_evaluation() { + let db = PgPool::connect_lazy("postgres://localhost/test").unwrap(); + let engine = ComplianceEngine::new(db, 3, 10, dec!(100000)); - assert_eq!(received, event.id); + assert_eq!(engine.volume_threshold, dec!(100000)); } - - // Additional integration tests would go here - // Test velocity detection logic - // Test volume threshold detection - // Test sanctions screening integration - // Test risk scoring algorithms - // Add compliance violation scenarios } diff --git a/backend/tests/compliance_integration_tests.rs b/backend/tests/compliance_integration_tests.rs index 8592dbea9..650d86791 100644 --- a/backend/tests/compliance_integration_tests.rs +++ b/backend/tests/compliance_integration_tests.rs @@ -43,6 +43,36 @@ async fn insert_test_plan(db: &PgPool, plan_id: Uuid, user_id: Uuid) { .unwrap(); } +async fn insert_old_test_plan(db: &PgPool, plan_id: Uuid, user_id: Uuid) { + sqlx::query( + r#"INSERT INTO plans (id, user_id, title, is_flagged, created_at) + VALUES ($1, $2, 'Old Test Plan', false, NOW() - INTERVAL '60 days')"#, + ) + .bind(plan_id) + .bind(user_id) + .execute(db) + .await + .unwrap(); +} + +async fn insert_test_lending_event(db: &PgPool, plan_id: Uuid, user_id: Uuid, event_type: &str, amount: &str, minutes_ago: i64) { + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, $4, $5, 'USD', NOW() - INTERVAL '1 minute' * $6) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .bind(event_type) + .bind(amount) + .bind(minutes_ago) + .execute(db) + .await + .unwrap(); +} + #[sqlx::test] async fn test_velocity_detection_logic(db: PgPool) { let user_id = Uuid::new_v4(); @@ -137,26 +167,184 @@ async fn test_compliance_violation_scenarios(db: PgPool) { let user_id = Uuid::new_v4(); let plan_id = Uuid::new_v4(); - insert_test_user_with_created_at(&db, user_id, Utc::now() - chrono::Duration::days(60)).await; + insert_test_user(&db, user_id).await; + insert_old_test_plan(&db, plan_id, user_id).await; - // Insert old plan with no recent activity + // Insert sudden borrow event sqlx::query( r#" - INSERT INTO plans (id, user_id, title, is_flagged, created_at) - VALUES ($1, $2, 'Old Plan', false, NOW() - INTERVAL '60 days') + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 5000, 'USD', NOW()) "#, ) + .bind(Uuid::new_v4()) .bind(plan_id) .bind(user_id) .execute(&db) .await .unwrap(); - // Insert sudden borrow event + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flagged: bool = sqlx::query_scalar("SELECT is_flagged FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(flagged, "Plan should be flagged for sudden activity spike"); +} + +#[sqlx::test] +async fn test_velocity_no_flag_below_threshold(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert events below threshold (2 events, threshold is 3) + for i in 0..2 { + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 1000, 'USD', NOW() - INTERVAL '1 minute' * $4) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .bind(i) + .execute(&db) + .await + .unwrap(); + } + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flagged: bool = sqlx::query_scalar("SELECT is_flagged FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(!flagged, "Plan should NOT be flagged when events are below threshold"); +} + +#[sqlx::test] +async fn test_velocity_events_outside_window_not_flagged(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert events OLDER than the velocity window (15 minutes, window is 10) + for i in 0..5 { + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 1000, 'USD', NOW() - INTERVAL '1 minute' * $4) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .bind(15 + i) + .execute(&db) + .await + .unwrap(); + } + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flagged: bool = sqlx::query_scalar("SELECT is_flagged FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(!flagged, "Plan should NOT be flagged when events are outside velocity window"); +} + +#[sqlx::test] +async fn test_velocity_exactly_at_threshold(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert exactly threshold number of events (3 events, threshold is 3) + for i in 0..3 { + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 1000, 'USD', NOW() - INTERVAL '1 minute' * $4) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .bind(i) + .execute(&db) + .await + .unwrap(); + } + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flagged: bool = sqlx::query_scalar("SELECT is_flagged FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(flagged, "Plan should be flagged when events exactly equal threshold"); +} + +#[sqlx::test] +async fn test_velocity_repay_events_included(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert mix of borrow and repay events (2 borrows + 1 repay = 3 total, threshold is 3) + for i in 0..2 { + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 1000, 'USD', NOW() - INTERVAL '1 minute' * $4) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .bind(i) + .execute(&db) + .await + .unwrap(); + } + + // Insert one repay event sqlx::query( r#" INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) - VALUES ($1, $2, $3, 'borrow', 5000, 'USD', NOW()) + VALUES ($1, $2, $3, 'repay', 500, 'USD', NOW()) "#, ) .bind(Uuid::new_v4()) @@ -177,7 +365,74 @@ async fn test_compliance_violation_scenarios(db: PgPool) { .await .unwrap_or(false); - assert!(flagged, "Plan should be flagged for sudden activity spike"); + assert!(flagged, "Plan should be flagged when borrow+repay events reach threshold"); +} + +#[sqlx::test] +async fn test_volume_below_threshold_not_flagged(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert volume below threshold + sqlx::query( + r#" + INSERT INTO lending_events (id, plan_id, user_id, event_type, amount, asset_code, event_timestamp) + VALUES ($1, $2, $3, 'borrow', 50000, 'USD', NOW()) + "#, + ) + .bind(Uuid::new_v4()) + .bind(plan_id) + .bind(user_id) + .execute(&db) + .await + .unwrap(); + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flagged: bool = sqlx::query_scalar("SELECT is_flagged FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(!flagged, "Plan should NOT be flagged when volume is below threshold"); +} + +#[sqlx::test] +async fn test_velocity_suspicion_flags_stored(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert 4 events to exceed threshold + for i in 0..4 { + insert_test_lending_event(&db, plan_id, user_id, "borrow", "1000", i).await; + } + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let flags: Option = sqlx::query_scalar("SELECT suspicion_flags FROM plans WHERE id = $1") + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap(); + + assert!(flags.is_some(), "suspicion_flags should be stored"); + let flags = flags.unwrap(); + assert!(flags.contains("High velocity detected")); + assert!(flags.contains("4 borrowing events")); + assert!(flags.contains("10 minutes")); } #[sqlx::test] @@ -278,3 +533,33 @@ async fn test_edge_cases_covered(db: PgPool) { // Should be flagged at exactly threshold assert!(flagged, "Plan should be flagged at volume threshold"); } + +#[sqlx::test] +async fn test_velocity_audit_log_created(db: PgPool) { + let user_id = Uuid::new_v4(); + let plan_id = Uuid::new_v4(); + + insert_test_user(&db, user_id).await; + insert_test_plan(&db, plan_id, user_id).await; + + // Insert 4 events to exceed threshold + for i in 0..4 { + insert_test_lending_event(&db, plan_id, user_id, "borrow", "1000", i).await; + } + + let engine = ComplianceEngine::new(db.clone(), 3, 10, dec!(100000)); + let engine = Arc::new(engine); + + engine.scan_suspicious_activity().await.unwrap(); + + let log_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM action_logs WHERE user_id = $1 AND action = 'suspicious_borrowing_detected' AND entity_id = $2)" + ) + .bind(user_id) + .bind(plan_id) + .fetch_one(&db) + .await + .unwrap_or(false); + + assert!(log_exists, "Audit log should be created for suspicious activity"); +}