//! Zero-knowledge integration tests. //! //! These verify the server-side ZK property: the server stores opaque //! ciphertext and never parses the contents of medication/appointment data //! blobs. Requires a live MongoDB (skips gracefully if unavailable). mod common; use common::{app_for_test, drop_test_db, send_json}; use serde_json::json; /// Skip the test when Mongo is unavailable (same pattern as auth_tests). macro_rules! require_app { ($app:expr) => { match $app { Some(x) => x, None => { eprintln!("[integration] skipped (MongoDB unavailable)"); return; } } }; } #[tokio::test] async fn medication_stored_as_ciphertext_not_plaintext() { let (app, db_name) = require_app!(app_for_test().await); // Register a user. let email = format!("zk_{}@example.com", uuid::Uuid::new_v4()); let (_, reg_body) = send_json( &app, "POST", "/api/auth/register", Some(json!({ "email": email, "username": "zkuser", "password": "dGVzdC1hdXRoLXNlY3JldA==", })), None, ) .await; let token = reg_body["token"].as_str().expect("missing token"); // Create a medication with an opaque blob. let opaque_data = "Y2lwaGVydGV4dC1iYXNlNjQ=".to_string(); let opaque_iv = "aXZ2aXZ2aXZ2aXZ2".to_string(); let (_, create_body) = send_json( &app, "POST", "/api/medications", Some(json!({ "profile_id": "default", "encrypted_data": { "data": opaque_data, "iv": opaque_iv }, })), Some(token), ) .await; assert_eq!(create_body["encrypted_data"]["data"], opaque_data); // The server must NOT expose plaintext fields like name/dosage. assert!( create_body.get("name").is_none(), "server leaked plaintext 'name' field" ); assert!( create_body.get("dosage").is_none(), "server leaked plaintext 'dosage' field" ); // GET the medication back — should echo the opaque blob. let med_id = create_body["medication_id"] .as_str() .expect("missing med_id"); let (status, get_body) = send_json( &app, "GET", &format!("/api/medications/{med_id}"), None, Some(token), ) .await; assert_eq!(status, 200); assert_eq!(get_body["encrypted_data"]["data"], opaque_data); drop_test_db(&db_name).await; } #[tokio::test] async fn appointment_stored_as_ciphertext_with_top_level_status() { let (app, db_name) = require_app!(app_for_test().await); let email = format!("zk2_{}@example.com", uuid::Uuid::new_v4()); let (_, reg_body) = send_json( &app, "POST", "/api/auth/register", Some(json!({ "email": email, "username": "zkuser2", "password": "dGVzdC1hdXRoLXNlY3JldA==", })), None, ) .await; let token = reg_body["token"].as_str().expect("missing token"); // Create an appointment with opaque blob + top-level status. let opaque_data = "YXB0LWNpcGhlcnRleHQ=".to_string(); let (_, create_body) = send_json( &app, "POST", "/api/appointments", Some(json!({ "profile_id": "default", "encrypted_data": { "data": opaque_data, "iv": "aXZ2aXZ2aXZ2" }, "status": "upcoming", })), Some(token), ) .await; // Status is a top-level field (not inside the encrypted blob). assert_eq!(create_body["status"], "upcoming"); // Server must NOT expose title/provider (those are encrypted). assert!(create_body.get("title").is_none(), "server leaked 'title'"); assert!( create_body.get("provider").is_none(), "server leaked 'provider'" ); assert_eq!(create_body["encrypted_data"]["data"], opaque_data); // List with status filter. let (status, list_body) = send_json( &app, "GET", "/api/appointments?status=upcoming", None, Some(token), ) .await; assert_eq!(status, 200); assert_eq!(list_body.as_array().unwrap().len(), 1); // Filter by a different status → should be empty. let (_, empty_list) = send_json( &app, "GET", "/api/appointments?status=completed", None, Some(token), ) .await; assert_eq!(empty_list.as_array().unwrap().len(), 0); drop_test_db(&db_name).await; } #[tokio::test] async fn health_stat_stored_as_ciphertext() { let (app, db_name) = require_app!(app_for_test().await); let email = format!("zk3_{}@example.com", uuid::Uuid::new_v4()); let (_, reg_body) = send_json( &app, "POST", "/api/auth/register", Some(json!({ "email": email, "username": "zkuser3", "password": "dGVzdC1hdXRoLXNlY3JldA==", })), None, ) .await; let token = reg_body["token"].as_str().expect("missing token"); let opaque_data = "aGVhbHRoLWNpcGhlcnRleHQ=".to_string(); let (_, create_body) = send_json( &app, "POST", "/api/health-stats", Some(json!({ "encrypted_data": { "data": opaque_data, "iv": "aXY=" }, "recorded_at": "2026-07-01T10:00:00Z", })), Some(token), ) .await; // Server must NOT expose value/unit/stat_type (those are encrypted). assert!(create_body.get("value").is_none(), "server leaked 'value'"); assert!( create_body.get("stat_type").is_none(), "server leaked 'stat_type'" ); assert_eq!(create_body["encrypted_data"]["data"], opaque_data); assert_eq!(create_body["recorded_at"], "2026-07-01T10:00:00Z"); // List — opaque blobs only. let (_, list_body) = send_json(&app, "GET", "/api/health-stats", None, Some(token)).await; let arr = list_body.as_array().unwrap(); assert_eq!(arr.len(), 1); assert!(arr[0].get("value").is_none(), "list leaked 'value'"); drop_test_db(&db_name).await; } #[tokio::test] async fn dose_schedule_adherence_reflects_missed_doses() { let (app, db_name) = require_app!(app_for_test().await); let email = format!("ds_{}@example.com", uuid::Uuid::new_v4()); let (_, reg_body) = send_json( &app, "POST", "/api/auth/register", Some(json!({ "email": email, "username": "dsuser", "password": "dGVzdC1hdXRoLXNlY3JldA==", })), None, ) .await; let token = reg_body["token"].as_str().expect("missing token"); // Create a medication with a dose schedule (1x/day, every day). let (_, create_body) = send_json( &app, "POST", "/api/medications", Some(json!({ "profile_id": "default", "encrypted_data": { "data": "Y3QA==", "iv": "aXY=" }, "dose_schedule": { "times_per_day": 1, "days_of_week": [] }, })), Some(token), ) .await; let med_id = create_body["medication_id"] .as_str() .expect("missing med_id"); // Log 1 taken dose. let _ = send_json( &app, "POST", &format!("/api/medications/{med_id}/log"), Some(json!({ "taken": true })), Some(token), ) .await; // Adherence: scheduled = 1×30 = 30, taken = 1, missed = 29. let (_, adh_body) = send_json( &app, "GET", &format!("/api/medications/{med_id}/adherence"), None, Some(token), ) .await; assert_eq!(adh_body["scheduled_doses"], 30); assert_eq!(adh_body["taken_doses"], 1); assert_eq!(adh_body["missed_doses"], 29); let rate = adh_body["adherence_rate"].as_f64().unwrap(); assert!((rate - (1.0 / 30.0 * 100.0)).abs() < 0.1); drop_test_db(&db_name).await; }