Three share_tests were failing in CI: 1. public_key_lookup_returns_recipient_identity_key — passed the email as a JSON body on a GET, but send_json builds the URI verbatim and ignores the body for GET. The query string was never formed, so the handler got an empty email. Fix: put ?email=... in the URI directly (URL-encoding the '@'). 2. public_key_lookup_404s_for_unknown_or_keyless_user — same root cause. 3. owner_shares_recipient_reads_owner_revokes — asserted the recipient's write would return 201 or 403, but create_medication returns 200 OK (Axum's default for Ok(Json(...))), and the write currently succeeds because the create handler doesn't check ownership (issue #12). The write is attributed to the recipient, not the owner, so the owner's data is still untouched — which is what the test really wants to prove. Accept 200 (current) or 403 (once #12 lands); documented the tie to #12.
456 lines
14 KiB
Rust
456 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())
|
|
}
|
|
|
|
/// URL-encode a value for a query string (emails contain '@').
|
|
fn q(email: &str) -> String {
|
|
email.replace('@', "%40")
|
|
}
|
|
|
|
/// 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",
|
|
&format!("/api/users/public-key?email={}", q(&owner_email)),
|
|
None,
|
|
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?email=does-not-exist%40example.com",
|
|
None,
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404);
|
|
|
|
// Existing user but no key.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/users/public-key?email={}", q(&keyless_email)),
|
|
None,
|
|
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 attempts a write to the shared profile. The medication create
|
|
// handler (a) doesn't enforce ownership on writes yet (issue #12) and
|
|
// (b) returns 200 OK (not 201) on success. So the write currently succeeds
|
|
// but is attributed to the RECIPIENT's user_id, not the owner — meaning it
|
|
// does NOT pollute the owner's view of that profile. Accept 200 (current,
|
|
// #12 not fixed) or 403 (once #12 lands). Either way the owner's records
|
|
// must be untouched.
|
|
let (write_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;
|
|
assert!(
|
|
write_status == 200 || write_status == 403,
|
|
"write outcome: {write_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;
|
|
}
|