normogen/backend/tests/share_tests.rs
goose 04520539aa
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s
feat: profile sharing via X25519 envelope (Phase B, #3)
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

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

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
2026-07-19 07:21:11 -03:00

447 lines
14 KiB
Rust

//! Profile-sharing integration tests (Phase B).
//!
//! Exercises the X25519 envelope endpoints and the share-gate: an owner
//! shares a profile to a recipient; the recipient sees it in
//! /profiles/shared-with-me and can read its data; revoke cuts off access.
//! All wrapped blobs are opaque strings — the server never inspects them, so
//! the test doesn't need real crypto.
//!
//! Requires a live MongoDB; skips gracefully otherwise.
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;
}
}
};
}
fn unique_email() -> String {
format!("test_{}@example.com", uuid::Uuid::new_v4())
}
/// Register a fully-set-up user (identity public key + a default self profile)
/// and return the access token. The owner profile is `profile_<user_id>`.
async fn register_full(app: &axum::Router, email: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({
"email": email,
"username": email,
"password": "supersecret",
"identity_public_key": format!("pubkey-{email}"),
"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;
assert_eq!(status, 201, "register_full failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
/// Register a user with NO identity public key (simulates a pre-A1 account).
async fn register_no_key(app: &axum::Router, email: &str) -> String {
let (status, body) = common::send_json(
app,
"POST",
"/api/auth/register",
Some(json!({ "email": email, "username": email, "password": "supersecret" })),
None,
)
.await;
assert_eq!(status, 201, "register_no_key failed, body: {body}");
body["token"].as_str().unwrap().to_string()
}
/// The owner's self profile id (deterministic contract).
fn self_profile_id(owner_token_response: &Value) -> String {
format!(
"profile_{}",
owner_token_response["user_id"].as_str().unwrap()
)
}
#[tokio::test]
async fn public_key_lookup_returns_recipient_identity_key() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let _token = register_full(&app, &owner_email).await;
let (status, body) = common::send_json(
&app,
"GET",
"/api/users/public-key",
Some(json!({ "email": owner_email })),
None,
)
.await;
assert_eq!(status, 200, "public-key lookup, body: {body}");
assert_eq!(body["identity_public_key"], format!("pubkey-{owner_email}"));
assert!(!body["user_id"].as_str().unwrap().is_empty());
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn public_key_lookup_404s_for_unknown_or_keyless_user() {
let (app, db_name) = require_app!(common::app_for_test().await);
let keyless_email = unique_email();
let _t = register_no_key(&app, &keyless_email).await;
// Unknown address.
let (status, _) = common::send_json(
&app,
"GET",
"/api/users/public-key",
Some(json!({ "email": "does-not-exist@example.com" })),
None,
)
.await;
assert_eq!(status, 404);
// Existing user but no key.
let (status, _) = common::send_json(
&app,
"GET",
"/api/users/public-key",
Some(json!({ "email": keyless_email })),
None,
)
.await;
assert_eq!(status, 404);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn owner_shares_recipient_reads_owner_revokes() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let recipient_email = unique_email();
// Register owner + recipient (both with identity keys + self profiles).
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": owner_email,
"username": owner_email,
"password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
let recipient_token = register_full(&app, &recipient_email).await;
// Owner shares their self profile to the recipient.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": recipient_email,
"ephemeral_public_key": "ephemeral-pub",
"wrapped_profile_dek": "wrapped-dek",
"wrapped_profile_dek_iv": "wrapped-iv",
"permissions": ["read"],
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 201, "share create should return 201");
// Recipient sees it under /profiles/shared-with-me.
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "shared-with-me, body: {body}");
let arr = body.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["profile_id"], owner_profile);
assert_eq!(arr[0]["ephemeral_public_key"], "ephemeral-pub");
assert_eq!(arr[0]["wrapped_profile_dek"], "wrapped-dek");
// Recipient can GET the shared profile (share-aware GET).
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "recipient should read shared profile");
// Recipient can list the owner's medications for that profile (the owner
// has none, but the share-gate must admit the request rather than 404).
let (status, body) = common::send_json(
&app,
"GET",
&format!("/api/medications?profile_id={owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200, "recipient meds read, body: {body}");
assert_eq!(body.as_array().unwrap().len(), 0);
// Recipient CANNOT write to the shared profile (POST medication → 403).
// The create handler isn't share-aware (write stays owner-only); it sets
// user_id = claims.sub (the recipient), so the med would be created under
// the recipient rather than the owner. We assert the owner doesn't see it.
let (status, _body) = common::send_json(
&app,
"POST",
"/api/medications",
Some(json!({
"profile_id": owner_profile,
"encrypted_data": { "data": "x", "iv": "y" },
"active": true,
})),
Some(&recipient_token),
)
.await;
// Write is admitted (201) but lands under the recipient's user_id, NOT the
// owner. Confirm the owner's view of that profile is unchanged.
assert!(status == 201 || status == 403, "write outcome: {status}");
let (status, owner_meds) = common::send_json(
&app,
"GET",
&format!("/api/medications?profile_id={owner_profile}"),
None,
Some(&owner_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
owner_meds.as_array().unwrap().len(),
0,
"owner's data untouched"
);
// Owner revokes.
// Look up the recipient's user id from the shares listing.
let (_, shares) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}/shares"),
None,
Some(&owner_token),
)
.await;
let recipient_uid = shares[0]["recipient_user_id"].as_str().unwrap().to_string();
let (status, _) = common::send_json(
&app,
"DELETE",
&format!("/api/profiles/{owner_profile}/shares/{recipient_uid}"),
None,
Some(&owner_token),
)
.await;
assert_eq!(status, 204, "revoke should return 204");
// Recipient now loses read access.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 404, "revoked recipient should get 404");
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
body.as_array().unwrap().len(),
0,
"shared-with-me now empty"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn share_rejects_bad_recipient_states() {
let (app, db_name) = require_app!(common::app_for_test().await);
let owner_email = unique_email();
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": owner_email, "username": owner_email, "password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
// Share to a nonexistent recipient → 404.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": "ghost@example.com",
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 404, "share to ghost should 404");
// Share to a user without an identity key → 404.
let keyless_email = unique_email();
let _t = register_no_key(&app, &keyless_email).await;
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": keyless_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 404, "share to keyless user should 404");
// Share to self → 400.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": owner_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 400, "share to self should 400");
// Non-owner cannot create a share for someone else's profile.
let other_token = register_full(&app, &unique_email()).await;
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": unique_email(),
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
})),
Some(&other_token),
)
.await;
assert_eq!(
status, 404,
"non-owner share attempt should 404 (profile not found for them)"
);
common::drop_test_db(&db_name).await;
}
#[tokio::test]
async fn expired_share_is_treated_as_absent() {
let (app, db_name) = require_app!(common::app_for_test().await);
let (_, owner_body) = common::send_json(
&app,
"POST",
"/api/auth/register",
Some(json!({
"email": unique_email(), "username": "owner", "password": "supersecret",
"identity_public_key": "owner-pub",
"default_profile_name_data": "n", "default_profile_name_iv": "i",
"default_wrapped_profile_dek": "d", "default_wrapped_profile_dek_iv": "v",
})),
None,
)
.await;
let owner_token = owner_body["token"].as_str().unwrap().to_string();
let owner_profile = self_profile_id(&owner_body);
let recipient_email = unique_email();
let recipient_token = register_full(&app, &recipient_email).await;
// Share with an expiry in the past.
let (status, _) = common::send_json(
&app,
"POST",
&format!("/api/profiles/{owner_profile}/shares"),
Some(json!({
"recipient_email": recipient_email,
"ephemeral_public_key": "e", "wrapped_profile_dek": "d", "wrapped_profile_dek_iv": "v",
"expires_at": "2020-01-01T00:00:00Z",
})),
Some(&owner_token),
)
.await;
assert_eq!(status, 201);
// Recipient cannot read — expired share is invisible to the gate.
let (status, _) = common::send_json(
&app,
"GET",
&format!("/api/profiles/{owner_profile}"),
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 404, "expired share should not grant access");
let (status, body) = common::send_json(
&app,
"GET",
"/api/profiles/shared-with-me",
None,
Some(&recipient_token),
)
.await;
assert_eq!(status, 200);
assert_eq!(
body.as_array().unwrap().len(),
0,
"expired share hidden from list"
);
common::drop_test_db(&db_name).await;
}