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.
255 lines
7.5 KiB
Rust
255 lines
7.5 KiB
Rust
//! Multi-profile integration tests (Phase A2).
|
|
//!
|
|
//! Exercises the /api/profiles CRUD endpoints: list, create, get, update,
|
|
//! delete — all ownership-scoped. A second registered user must not be able
|
|
//! to read or modify the first user's profiles.
|
|
//!
|
|
//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs).
|
|
|
|
mod common;
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
macro_rules! require_app {
|
|
($app:expr) => {
|
|
match $app {
|
|
Some(x) => x,
|
|
None => {
|
|
eprintln!("[integration] skipped (MongoDB unavailable)");
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Register a user (sending no default-profile fields → no profile auto-created)
|
|
/// and return the access token.
|
|
async fn register(app: &axum::Router, email: &str, password: &str) -> String {
|
|
let (status, body) = common::send_json(
|
|
app,
|
|
"POST",
|
|
"/api/auth/register",
|
|
Some(json!({ "email": email, "username": "tester", "password": password })),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, 201, "register precondition failed, body: {body}");
|
|
body["token"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
fn unique_email() -> String {
|
|
format!("test_{}@example.com", uuid::Uuid::new_v4())
|
|
}
|
|
|
|
/// Body for creating a profile with a fake wrapped DEK (the server stores it
|
|
/// verbatim; it never inspects the contents).
|
|
fn create_body(kind: &str, relationship: &str) -> Value {
|
|
json!({
|
|
"kind": kind,
|
|
"relationship": relationship,
|
|
"name_data": "base64-encrypted-name",
|
|
"name_iv": "base64-iv",
|
|
"wrapped_profile_dek": "base64-wrapped-profile-dek",
|
|
"wrapped_profile_dek_iv": "base64-dek-iv",
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_starts_empty_without_default_profile() {
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token = register(&app, &unique_email(), "supersecret").await;
|
|
|
|
let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
|
assert_eq!(status, 200, "list should return 200, body: {body}");
|
|
assert_eq!(
|
|
body.as_array().unwrap().len(),
|
|
0,
|
|
"no default profile expected"
|
|
);
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_then_list_then_get_update_delete() {
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token = register(&app, &unique_email(), "supersecret").await;
|
|
|
|
// Create a profile.
|
|
let (status, body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/profiles",
|
|
Some(create_body("human", "self")),
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 201, "create should return 201, body: {body}");
|
|
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
|
assert_eq!(body["kind"], "human");
|
|
assert_eq!(body["relationship"], "self");
|
|
assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek");
|
|
|
|
// List now has one.
|
|
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
|
assert_eq!(list_body.as_array().unwrap().len(), 1);
|
|
|
|
// Get by id.
|
|
let (status, get_body) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200);
|
|
assert_eq!(get_body["profile_id"], profile_id);
|
|
|
|
// Update the display-name blob + relationship.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"PUT",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
Some(json!({
|
|
"name_data": "new-name-blob",
|
|
"name_iv": "new-iv",
|
|
"kind": "pet",
|
|
"relationship": "dog",
|
|
})),
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "update should return 200");
|
|
|
|
// Delete.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"DELETE",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 204, "delete should return 204");
|
|
|
|
// Get now 404s.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404);
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn other_user_cannot_access_profile() {
|
|
// Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile.
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token_a = register(&app, &unique_email(), "supersecret").await;
|
|
let token_b = register(&app, &unique_email(), "supersecret").await;
|
|
|
|
// A creates a profile.
|
|
let (_, body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/profiles",
|
|
Some(create_body("human", "child")),
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
let profile_id = body["profile_id"].as_str().unwrap().to_string();
|
|
|
|
// B cannot GET it (404, because ownership filter excludes it).
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not read A's profile");
|
|
|
|
// B cannot UPDATE it.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"PUT",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
Some(json!({
|
|
"name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self"
|
|
})),
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not update A's profile");
|
|
|
|
// B cannot DELETE it.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"DELETE",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not delete A's profile");
|
|
|
|
// A still sees it intact.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/profiles/{profile_id}"),
|
|
None,
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "A's profile must be untouched");
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn register_with_default_profile_creates_self_profile() {
|
|
// When register carries default_wrapped_profile_dek, the self profile is
|
|
// auto-created and shows up in GET /api/profiles.
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let email = unique_email();
|
|
|
|
let (_, body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/auth/register",
|
|
Some(json!({
|
|
"email": email,
|
|
"username": "tester",
|
|
"password": "supersecret",
|
|
"default_profile_name_data": "name-blob",
|
|
"default_profile_name_iv": "name-iv",
|
|
"default_wrapped_profile_dek": "dek-blob",
|
|
"default_wrapped_profile_dek_iv": "dek-iv",
|
|
})),
|
|
None,
|
|
)
|
|
.await;
|
|
let token = body["token"].as_str().unwrap().to_string();
|
|
|
|
let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await;
|
|
let arr = list_body.as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "default self profile should exist");
|
|
assert_eq!(arr[0]["relationship"], "self");
|
|
assert_eq!(arr[0]["kind"], "human");
|
|
assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob");
|
|
// Deterministic id contract: profile_<owner>.
|
|
assert!(arr[0]["profile_id"]
|
|
.as_str()
|
|
.unwrap()
|
|
.starts_with("profile_"));
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|