//! Hard-revoke / re-key integration tests (Phase C). //! //! Verifies POST /api/profiles/:id/rekey: rotating the profile's wrapped DEK, //! upserting submitted recipient envelopes (fresh ECDH wraps), and hard- //! deleting any current recipient NOT in the submitted list. Wire-level only //! (opaque blobs — the server never inspects them, so the tests don'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 key + default self profile). async fn register_full(app: &axum::Router, email: &str) -> Value { let (status, body) = common::send_json( app, "POST", "/api/auth/register", Some(json!({ "email": email, "username": email, "password": "supersecret", "identity_public_key": format!("pub-{email}"), "default_profile_name_data": "n", "default_profile_name_iv": "i", "default_wrapped_profile_dek": "owner-dek-v1", "default_wrapped_profile_dek_iv": "owner-dek-iv-v1", })), None, ) .await; assert_eq!(status, 201, "register_full failed, body: {body}"); body } fn token(body: &Value) -> String { body["token"].as_str().unwrap().to_string() } fn self_profile_id(body: &Value) -> String { format!("profile_{}", body["user_id"].as_str().unwrap()) } /// Owner shares profile to recipient_email; returns the recipient's user_id /// (looked up from the share listing afterward). async fn share_to( app: &axum::Router, owner_token: &str, profile_id: &str, recipient_email: &str, ) -> String { let (status, _) = common::send_json( app, "POST", &format!("/api/profiles/{profile_id}/shares"), Some(json!({ "recipient_email": recipient_email, "ephemeral_public_key": format!("eph-{recipient_email}"), "wrapped_profile_dek": format!("wrap-{recipient_email}"), "wrapped_profile_dek_iv": "iv", "permissions": ["read"], })), Some(owner_token), ) .await; assert_eq!(status, 201, "share_to failed"); // Look up the recipient's user_id from the listing. let (_, listing) = common::send_json( app, "GET", &format!("/api/profiles/{profile_id}/shares"), None, Some(owner_token), ) .await; listing .as_array() .unwrap() .iter() .find(|s| s["recipient_email"] == recipient_email) .unwrap()["recipient_user_id"] .as_str() .unwrap() .to_string() } #[tokio::test] async fn rekey_rotates_owner_share_and_keeps_listed_recipients() { let (app, db_name) = require_app!(common::app_for_test().await); let owner_body = register_full(&app, &unique_email()).await; let owner_token = token(&owner_body); let profile_id = self_profile_id(&owner_body); let recipient_b = unique_email(); let recipient_c = unique_email(); register_full(&app, &recipient_b).await; register_full(&app, &recipient_c).await; let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; // Owner rekeys: keeps B (fresh envelope), omits C (hard-revoke). let (status, body) = common::send_json( &app, "POST", &format!("/api/profiles/{profile_id}/rekey"), Some(json!({ "new_wrapped_profile_dek": "owner-dek-v2", "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", "recipient_envelopes": [{ "recipient_user_id": b_uid, "ephemeral_public_key": "eph-b-v2", "wrapped_profile_dek": "wrap-b-v2", "wrapped_profile_dek_iv": "iv-v2", }], })), Some(&owner_token), ) .await; assert_eq!(status, 200, "rekey should return 200, body: {body}"); // Owner share rotated. assert_eq!(body["wrapped_profile_dek"], "owner-dek-v2"); assert_eq!(body["wrapped_profile_dek_iv"], "owner-dek-iv-v2"); // Retained = [B]. let retained: Vec<&str> = body["retained_recipient_user_ids"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect(); assert_eq!(retained, vec![b_uid.as_str()]); // The profile's stored owner-share reflects v2. let (_, profile_body) = common::send_json( &app, "GET", &format!("/api/profiles/{profile_id}"), None, Some(&owner_token), ) .await; assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v2"); common::drop_test_db(&db_name).await; } #[tokio::test] async fn rekey_hard_revokes_omitted_recipient() { let (app, db_name) = require_app!(common::app_for_test().await); let owner_body = register_full(&app, &unique_email()).await; let owner_token = token(&owner_body); let profile_id = self_profile_id(&owner_body); let recipient_b = unique_email(); let recipient_c = unique_email(); let b_body = register_full(&app, &recipient_b).await; let c_body = register_full(&app, &recipient_c).await; let b_token = token(&b_body); let c_token = token(&c_body); let b_uid = share_to(&app, &owner_token, &profile_id, &recipient_b).await; let _c_uid = share_to(&app, &owner_token, &profile_id, &recipient_c).await; // Before rekey: both recipients see the profile in shared-with-me. let (_, shared_before) = common::send_json( &app, "GET", "/api/profiles/shared-with-me", None, Some(&c_token), ) .await; assert_eq!( shared_before.as_array().unwrap().len(), 1, "C should see the shared profile before rekey" ); // Rekey keeping only B. let (status, _) = common::send_json( &app, "POST", &format!("/api/profiles/{profile_id}/rekey"), Some(json!({ "new_wrapped_profile_dek": "owner-dek-v2", "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", "recipient_envelopes": [{ "recipient_user_id": b_uid, "ephemeral_public_key": "eph-b-v2", "wrapped_profile_dek": "wrap-b-v2", "wrapped_profile_dek_iv": "iv-v2", }], })), Some(&owner_token), ) .await; assert_eq!(status, 200); // B (kept) still sees it, with the rotated envelope. let (_, shared_b) = common::send_json( &app, "GET", "/api/profiles/shared-with-me", None, Some(&b_token), ) .await; let arr_b = shared_b.as_array().unwrap(); assert_eq!(arr_b.len(), 1, "B sees exactly the one shared profile"); assert_eq!( arr_b[0]["wrapped_profile_dek"], "wrap-b-v2", "B's envelope rotated" ); // C (omitted) no longer sees it. let (_, shared_c) = common::send_json( &app, "GET", "/api/profiles/shared-with-me", None, Some(&c_token), ) .await; assert_eq!( shared_c.as_array().unwrap().len(), 0, "C must be hard-revoked from shared-with-me" ); // And the share-gate 404s C on direct read. let (status, _) = common::send_json( &app, "GET", &format!("/api/profiles/{profile_id}"), None, Some(&c_token), ) .await; assert_eq!( status, 404, "C must be denied by the gate after hard revoke" ); common::drop_test_db(&db_name).await; } #[tokio::test] async fn rekey_rejects_envelope_for_recipient_with_no_share() { let (app, db_name) = require_app!(common::app_for_test().await); let owner_body = register_full(&app, &unique_email()).await; let owner_token = token(&owner_body); let profile_id = self_profile_id(&owner_body); // A recipient who was never shared with. let stranger_body = register_full(&app, &unique_email()).await; let stranger_uid = stranger_body["user_id"].as_str().unwrap().to_string(); let (status, body) = common::send_json( &app, "POST", &format!("/api/profiles/{profile_id}/rekey"), Some(json!({ "new_wrapped_profile_dek": "owner-dek-v2", "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", "recipient_envelopes": [{ "recipient_user_id": stranger_uid, "ephemeral_public_key": "x", "wrapped_profile_dek": "y", "wrapped_profile_dek_iv": "z", }], })), Some(&owner_token), ) .await; assert_eq!( status, 400, "envelope for a recipient with no share must be rejected: {body}" ); common::drop_test_db(&db_name).await; } #[tokio::test] async fn rekey_rejects_non_owner() { let (app, db_name) = require_app!(common::app_for_test().await); let owner_body = register_full(&app, &unique_email()).await; let profile_id = self_profile_id(&owner_body); let other_token = token(®ister_full(&app, &unique_email()).await); let (status, _) = common::send_json( &app, "POST", &format!("/api/profiles/{profile_id}/rekey"), Some(json!({ "new_wrapped_profile_dek": "x", "new_wrapped_profile_dek_iv": "y", "recipient_envelopes": [], })), Some(&other_token), ) .await; assert_eq!(status, 404, "non-owner rekey must be rejected"); // Profile is unchanged. let (_, profile_body) = common::send_json( &app, "GET", &format!("/api/profiles/{profile_id}"), None, Some(token(&owner_body).as_str()), ) .await; assert_eq!(profile_body["wrapped_profile_dek"], "owner-dek-v1"); common::drop_test_db(&db_name).await; } #[tokio::test] async fn rekey_with_empty_envelopes_revokes_all_recipients() { // Keeping nobody = hard-revoke everyone (e.g. suspected compromise). let (app, db_name) = require_app!(common::app_for_test().await); let owner_body = register_full(&app, &unique_email()).await; let owner_token = token(&owner_body); let profile_id = self_profile_id(&owner_body); let recipient_b = unique_email(); register_full(&app, &recipient_b).await; share_to(&app, &owner_token, &profile_id, &recipient_b).await; let (status, body) = common::send_json( &app, "POST", &format!("/api/profiles/{profile_id}/rekey"), Some(json!({ "new_wrapped_profile_dek": "owner-dek-v2", "new_wrapped_profile_dek_iv": "owner-dek-iv-v2", "recipient_envelopes": [], })), Some(&owner_token), ) .await; assert_eq!( status, 200, "rekey with no envelopes should succeed: {body}" ); assert_eq!( body["retained_recipient_user_ids"] .as_array() .unwrap() .len(), 0 ); // The shares listing is now empty. let (_, listing) = common::send_json( &app, "GET", &format!("/api/profiles/{profile_id}/shares"), None, Some(&owner_token), ) .await; assert_eq!(listing.as_array().unwrap().len(), 0); common::drop_test_db(&db_name).await; }