normogen/backend/tests/profile_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

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