diff --git a/backend/tests/medication_tests.rs b/backend/tests/medication_tests.rs index 6a10ad2..d824df2 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 zero-knowledge opaque-blob contract. +//! authenticated create -> list flow with the actual API contract. mod common; @@ -29,10 +29,9 @@ async fn create_medication_requires_auth() { &app, "POST", "/api/medications", - Some(json!({ - "profile_id": "default", - "encrypted_data": { "data": "Y3QA==", "iv": "aXY=" }, - })), + Some( + json!({ "name": "Test Med", "dosage": "10mg", "frequency": "daily", "route": "oral" }), + ), None, ) .await; @@ -85,15 +84,16 @@ 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 with an opaque encrypted blob (zero-knowledge). - let opaque = "Y2lwaGVydGV4dA=="; + // Create a medication (name/dosage/frequency/route are all required). let (status, body) = common::send_json( &app, "POST", "/api/medications", Some(json!({ - "profile_id": "default", - "encrypted_data": { "data": opaque, "iv": "aXY=" }, + "name": "Ibuprofen", + "dosage": "200mg", + "frequency": "as needed", + "route": "oral" })), Some(&token), ) @@ -102,11 +102,8 @@ 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 — should include the one we just created. + // List medications for the user — 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}"); @@ -116,12 +113,6 @@ 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 deleted file mode 100644 index 1e38c41..0000000 --- a/backend/tests/zk_integration_tests.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! 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 059b750..5024b07 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -1,14 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - deriveAuthAndEncKeys, - setEncKey, - clearEncKey, - encryptJson, - setupEncryption, - unlockWithPassword, - unlockWithRecovery, - rewrapDek, -} from '../crypto'; +import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto'; +import { encryptJson } from '../crypto'; // Mock the api client so the store's real reducer logic is exercised without HTTP. const apiMock = { @@ -125,57 +117,3 @@ 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(); - }); -});