diff --git a/backend/tests/medication_tests.rs b/backend/tests/medication_tests.rs index d824df2..6a10ad2 100644 --- a/backend/tests/medication_tests.rs +++ b/backend/tests/medication_tests.rs @@ -2,7 +2,7 @@ //! //! In-process against an isolated MongoDB test DB (see `common`). When Mongo is //! unreachable these skip gracefully. Verifies auth enforcement and an -//! authenticated create -> list flow with the actual API contract. +//! authenticated create -> list flow with the zero-knowledge opaque-blob contract. mod common; @@ -29,9 +29,10 @@ async fn create_medication_requires_auth() { &app, "POST", "/api/medications", - Some( - json!({ "name": "Test Med", "dosage": "10mg", "frequency": "daily", "route": "oral" }), - ), + Some(json!({ + "profile_id": "default", + "encrypted_data": { "data": "Y3QA==", "iv": "aXY=" }, + })), None, ) .await; @@ -84,16 +85,15 @@ async fn authenticated_user_can_create_and_list_medication() { assert_eq!(status, 201); let token = body["token"].as_str().unwrap().to_string(); - // Create a medication (name/dosage/frequency/route are all required). + // Create a medication with an opaque encrypted blob (zero-knowledge). + let opaque = "Y2lwaGVydGV4dA=="; let (status, body) = common::send_json( &app, "POST", "/api/medications", Some(json!({ - "name": "Ibuprofen", - "dosage": "200mg", - "frequency": "as needed", - "route": "oral" + "profile_id": "default", + "encrypted_data": { "data": opaque, "iv": "aXY=" }, })), Some(&token), ) @@ -102,8 +102,11 @@ async fn authenticated_user_can_create_and_list_medication() { status == 200 || status == 201, "create should succeed, body: {body}" ); + // Response should echo the opaque blob, not expose plaintext fields. + assert_eq!(body["encrypted_data"]["data"], opaque); + assert!(body.get("name").is_none(), "server leaked plaintext"); - // List medications for the user — should include the one we just created. + // List medications — should include the one we just created. let (status, body) = common::send_json(&app, "GET", "/api/medications", None, Some(&token)).await; assert_eq!(status, 200, "list should succeed, body: {body}"); @@ -113,6 +116,12 @@ async fn authenticated_user_can_create_and_list_medication() { !arr.is_empty(), "list should contain the created medication" ); + // Each list item should be opaque. + assert!( + arr[0].get("encrypted_data").is_some(), + "list item has encrypted_data" + ); + assert!(arr[0].get("name").is_none(), "list item leaked plaintext"); common::drop_test_db(&db_name).await; } diff --git a/backend/tests/zk_integration_tests.rs b/backend/tests/zk_integration_tests.rs new file mode 100644 index 0000000..1e38c41 --- /dev/null +++ b/backend/tests/zk_integration_tests.rs @@ -0,0 +1,268 @@ +//! 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; +} diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 5024b07..059b750 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto'; -import { encryptJson } from '../crypto'; +import { + deriveAuthAndEncKeys, + setEncKey, + clearEncKey, + encryptJson, + setupEncryption, + unlockWithPassword, + unlockWithRecovery, + rewrapDek, +} from '../crypto'; // Mock the api client so the store's real reducer logic is exercised without HTTP. const apiMock = { @@ -117,3 +125,57 @@ describe('useMedicationStore', () => { expect(useMedicationStore.getState().adherence['m1'].adherence_rate).toBe(100); }); }); + +describe('zero-knowledge medication lifecycle (register → create → recover)', () => { + // This test exercises the full ZK flow using real WebCrypto + the real store, + // with mocked HTTP. It proves: encrypt-on-write, decrypt-on-read, and that + // data encrypted under one key can be recovered via the recovery phrase and + // decrypted under a re-wrapped key. + it('full lifecycle: data survives password recovery', async () => { + const password = 'lifecycle-password'; + const recoveryPhrase = 'lifecycle-recovery'; + const medData = { name: 'Lisinopril', dosage: '10mg', frequency: 'daily' }; + + // 1. Setup encryption (register): generate DEK, wrap under password + recovery. + const setup = await setupEncryption(password, recoveryPhrase); + setEncKey(setup.dek); + + // 2. Encrypt the medication data (what the store does on create). + const encrypted = await encryptJson(medData, setup.dek); + expect(encrypted.data).not.toContain('Lisinopril'); // ciphertext, not plaintext + + // 3. Simulate page reload: DEK is lost. + clearEncKey(); + + // 4. Unlock with password (the unlock screen). + const unlockResult = await unlockWithPassword(password, setup.passwordWrappedDek); + setEncKey(unlockResult.dek); + + // 5. Decrypt the data with the unlocked DEK. + const decrypted1 = await encryptJson(medData, unlockResult.dek).then((enc) => + // We can't decrypt what we just encrypted in the same call; use the + // original encrypted blob instead. + Promise.resolve(enc), + ); + // Use the ORIGINAL encrypted blob (from step 2) to verify round-trip. + const { decryptJson } = await import('../crypto'); + const roundTripped = await decryptJson(encrypted, unlockResult.dek); + expect(roundTripped.name).toBe('Lisinopril'); + + // 6. Simulate forgot-password: recover via recovery phrase. + clearEncKey(); + const recoveredDek = await unlockWithRecovery(recoveryPhrase, setup.recoveryWrappedDek!); + const recoveredData = await decryptJson(encrypted, recoveredDek); + expect(recoveredData.name).toBe('Lisinopril'); + expect(recoveredData.dosage).toBe('10mg'); + + // 7. Re-wrap under a new password (post-recovery). + const newPassword = 'new-lifecycle-password'; + const rewrapped = await rewrapDek(recoveredDek, newPassword); + const newUnlock = await unlockWithPassword(newPassword, rewrapped); + const finalData = await decryptJson(encrypted, newUnlock.dek); + expect(finalData.name).toBe('Lisinopril'); + + clearEncKey(); + }); +});