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.
This commit is contained in:
parent
9807434c5f
commit
eb2c2aa546
25 changed files with 1322 additions and 206 deletions
255
backend/tests/profile_tests.rs
Normal file
255
backend/tests/profile_tests.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! 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;
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() {
|
|||
"POST",
|
||||
"/api/health-stats",
|
||||
Some(json!({
|
||||
"profile_id": "default",
|
||||
"encrypted_data": { "data": opaque_data, "iv": "aXY=" },
|
||||
"recorded_at": "2026-07-01T10:00:00Z",
|
||||
})),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue