//! 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_`. 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; }