normogen/backend/tests/zk_integration_tests.rs
goose eb2c2aa546
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s
feat: per-profile DEKs + multi-profile (Phase A2, #3)
Implements the 3-tier key model from the multi-person sharing ADR:
each account owns multiple profiles (a person or pet — a 'subject of
care'), and each profile has its own random AES-256-GCM DEK. All
health data is now encrypted under the active profile's DEK, not the
account-wide DEK. The account DEK wraps each profile DEK; the server
stores only opaque wrapped blobs.

Per the ADR: DB wipe, no migration (no real user data). This unblocks
Phase B (sharing) — there is now a per-profile key to wrap to a
recipient's X25519 public key.

Backend:
- Profile model: owner_account_id, kind (human/pet), relationship,
  wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner,
  find_by_profile_id_owned, update_profile, delete_profile — all
  ownership-scoped.
- Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE
  /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs
  get_profile/update_profile (the /api/users/me handlers) to
  get_account/update_account to resolve a name collision.
- Register accepts default_profile_* fields and auto-creates the self
  profile when the client provides a wrapped profile DEK.
- HealthStatistic + Appointment gain profile_id and ?profile_id=
  filtering (health stats previously had no profile binding).
- New profile_tests.rs: multi-profile CRUD + ownership isolation +
  register-with-default-profile. Fixed the zk health-stat test to
  send the now-required profile_id.

Frontend:
- crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek
  + in-memory per-profile DEK store with an active-profile concept.
- useProfileStore rewritten: holds profiles[], activeProfileId;
  loadProfiles unwraps each profile DEK; create/update/delete. All 11
  encrypt/decrypt sites switched from getEncKey() to
  getActiveProfileDek(). load actions pass ?profile_id= so only the
  active profile's rows come back.
- ProfileEditor rewritten for the new store (edit active profile,
  create/delete). New ProfileSwitcher in the Dashboard AppBar.
- MedicationManager / AppointmentsManager use the active profile id
  instead of the hardcoded profile_<user_id>.
- 2 new crypto tests for per-profile DEK isolation; updated store +
  component tests for the active-profile-DEK model.

Verification: backend cargo build/clippy/fmt green, tests compile
(integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 30/30 tests pass.

Closes nothing yet (Phase B/C/D remain). Refs #3.
2026-07-18 23:45:01 -03:00

269 lines
7.8 KiB
Rust
Raw Permalink 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!({
"profile_id": "default",
"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;
}