normogen/backend/tests/share_tests.rs
goose e28135bc08
Some checks failed
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m39s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 3m43s
test(share): fix query-string encoding + write-status assertion
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.
2026-07-19 10:37:40 -03:00

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