normogen/backend/tests/zk_integration_tests.rs
goose 34e3b0b0e0 test: E2E ZK integration tests + frontend lifecycle test
Backend ZK integration tests (tests/zk_integration_tests.rs):
- medication_stored_as_ciphertext: register → create med with opaque blob →
  response echoes blob, no plaintext fields leaked → GET echoes blob.
- appointment_stored_as_ciphertext_with_top_level_status: opaque blob + status
  filter works server-side without decryption.
- health_stat_stored_as_ciphertext: opaque blob, no value/stat_type leaked.
- dose_schedule_adherence_reflects_missed_doses: 1×/day schedule → 30 scheduled,
  1 taken, 29 missed, ~3.3% rate.

Updated medication_tests.rs for the opaque-blob contract (was sending old
plaintext name/dosage fields).

Frontend lifecycle test (useStore.test.ts):
- Full ZK round-trip with real WebCrypto: setup → encrypt → verify ciphertext →
  unlock with password → decrypt → recover via phrase → rewrap under new
  password → decrypt with new key. Data survives the full lifecycle.

Verified: backend 24 tests 0 warnings; frontend 25 tests, build clean.
2026-07-05 00:35:33 -03:00

268 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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;
}