The update/delete handlers for medications and appointments took the auth Claims but never used them — any authenticated user could update or delete any other user's records by id (an IDOR). Same gap on log_dose (could skew another's adherence stats) and get_adherence (leaked dose history). Fix: each affected handler now looks up the item first and confirms user_id == claims.sub before mutating/returning. Mismatches return 404 (not 403) to avoid leaking the existence of other users' records. Recipients of shared profiles remain read-only per the ADR — writes stay owner-only. Covered handlers: - update_medication, delete_medication, log_dose, get_adherence - update_appointment, delete_appointment health_stats update/delete were already checking user_id == claims.sub (unaffected). New ownership_tests.rs: cross-user update/delete/log-dose/adherence all 404; the legitimate owner can still do all of the above. Verification: cargo build/clippy (-D warnings)/fmt clean; existing tests unaffected (they operate as the same user that created the data). Closes #12.
258 lines
7.5 KiB
Rust
258 lines
7.5 KiB
Rust
//! Write/delete path ownership tests (#12).
|
|
//!
|
|
//! Verifies the ownership checks added to fix the IDOR: user A creates a
|
|
//! medication/appointment; user B cannot update, delete, log-dose-against, or
|
|
//! read adherence for it. All cross-user attempts must 404 (we use 404, not
|
|
//! 403, to avoid leaking the existence of other users' records).
|
|
//!
|
|
//! 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 user and return the access token.
|
|
async fn register(app: &axum::Router, email: &str) -> String {
|
|
let (status, body) = common::send_json(
|
|
app,
|
|
"POST",
|
|
"/api/auth/register",
|
|
Some(json!({ "email": email, "username": "tester", "password": "supersecret" })),
|
|
None,
|
|
)
|
|
.await;
|
|
assert_eq!(status, 201, "register failed, body: {body}");
|
|
body["token"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
/// Create a medication as `token` and return its medication_id.
|
|
async fn create_medication(app: &axum::Router, token: &str) -> String {
|
|
let (status, body) = common::send_json(
|
|
app,
|
|
"POST",
|
|
"/api/medications",
|
|
Some(json!({
|
|
"profile_id": "default",
|
|
"encrypted_data": { "data": "b3duZXItYmxvYg==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
|
})),
|
|
Some(token),
|
|
)
|
|
.await;
|
|
assert!(
|
|
status == 200 || status == 201,
|
|
"create_medication failed: {status} {body}"
|
|
);
|
|
body["medication_id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
/// Create an appointment as `token` and return its appointment_id.
|
|
async fn create_appointment(app: &axum::Router, token: &str) -> String {
|
|
let (status, body) = common::send_json(
|
|
app,
|
|
"POST",
|
|
"/api/appointments",
|
|
Some(json!({
|
|
"profile_id": "default",
|
|
"encrypted_data": { "data": "YXBwdC1ibG9i", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
|
"status": "upcoming",
|
|
})),
|
|
Some(token),
|
|
)
|
|
.await;
|
|
assert!(
|
|
status == 200 || status == 201,
|
|
"create_appointment failed: {status} {body}"
|
|
);
|
|
body["appointment_id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn other_user_cannot_update_or_delete_medication() {
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token_a = register(&app, &unique_email()).await;
|
|
let token_b = register(&app, &unique_email()).await;
|
|
let med_id = create_medication(&app, &token_a).await;
|
|
|
|
// B cannot UPDATE A's medication.
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}"),
|
|
Some(json!({
|
|
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
|
})),
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not update A's medication");
|
|
|
|
// B cannot DELETE A's medication.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}/delete"),
|
|
None,
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not delete A's medication");
|
|
|
|
// A still sees it intact.
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/medications/{med_id}"),
|
|
None,
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "A's medication must be untouched");
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn other_user_cannot_log_dose_or_read_adherence_for_others_medication() {
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token_a = register(&app, &unique_email()).await;
|
|
let token_b = register(&app, &unique_email()).await;
|
|
let med_id = create_medication(&app, &token_a).await;
|
|
|
|
// B cannot LOG A DOSE against A's medication (would skew A's adherence).
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}/log"),
|
|
Some(json!({ "taken": true })),
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not log a dose against A's medication");
|
|
|
|
// B cannot READ A's adherence (leaks dose history).
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/medications/{med_id}/adherence"),
|
|
None,
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not read A's adherence");
|
|
|
|
// A can both (sanity).
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}/log"),
|
|
Some(json!({ "taken": true })),
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 201, "A should be able to log a dose: {_body}");
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/medications/{med_id}/adherence"),
|
|
None,
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "A should be able to read adherence");
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn other_user_cannot_update_or_delete_appointment() {
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token_a = register(&app, &unique_email()).await;
|
|
let token_b = register(&app, &unique_email()).await;
|
|
let appt_id = create_appointment(&app, &token_a).await;
|
|
|
|
// B cannot UPDATE A's appointment.
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/appointments/{appt_id}"),
|
|
Some(json!({
|
|
"encrypted_data": { "data": "aGFja2Vk", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
|
"status": "cancelled",
|
|
})),
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not update A's appointment");
|
|
|
|
// B cannot DELETE A's appointment.
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/appointments/{appt_id}/delete"),
|
|
None,
|
|
Some(&token_b),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 404, "B must not delete A's appointment");
|
|
|
|
// A still sees it.
|
|
let (status, _body): (u16, Value) = common::send_json(
|
|
&app,
|
|
"GET",
|
|
&format!("/api/appointments/{appt_id}"),
|
|
None,
|
|
Some(&token_a),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "A's appointment must be untouched");
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn owner_can_update_and_delete_own_medication() {
|
|
// Sanity: the new checks must not block the legitimate owner.
|
|
let (app, db_name) = require_app!(common::app_for_test().await);
|
|
let token = register(&app, &unique_email()).await;
|
|
let med_id = create_medication(&app, &token).await;
|
|
|
|
let (status, _body) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}"),
|
|
Some(json!({
|
|
"encrypted_data": { "data": "dXBkYXRlZA==", "iv": "aXZ2aXZ2aXZ2aXZ2" },
|
|
})),
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "owner should update: {_body}");
|
|
|
|
let (status, _) = common::send_json(
|
|
&app,
|
|
"POST",
|
|
&format!("/api/medications/{med_id}/delete"),
|
|
None,
|
|
Some(&token),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 204, "owner should delete");
|
|
|
|
common::drop_test_db(&db_name).await;
|
|
}
|