use axum::Extension; use axum::Json; use axum::extract::{FromRef, Path, Query, State}; use axum::response::IntoResponse; use sea_orm::*; use serde::Deserialize; use uuid::Uuid; use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::TenantContext; use crate::fhir::converter; use crate::fhir::types::{category_to_device_types, loinc_to_device_type}; use crate::oauth::middleware::FhirAuthContext; use crate::state::HealthState; /// GET /fhir/R4/metadata — FHIR CapabilityStatement pub async fn capability_statement() -> Result where HealthState: FromRef, S: Clone + Send + Sync + 'static, { let stmt = serde_json::json!({ "resourceType": "CapabilityStatement", "status": "active", "date": chrono::Utc::now().format("%Y-%m-%d").to_string(), "kind": "instance", "fhirVersion": "4.0.1", "format": ["application/fhir+json"], "rest": [{ "mode": "server", "resource": [ { "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}], "operation": [{"name": "everything"}] }, { "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Practitioner", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Appointment", "interaction": [{"code": "read"}, {"code": "search-type"}] }, { "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] }, ], "operation": [ { "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" } ] }] }); Ok(Json(stmt)) } #[derive(Debug, Deserialize)] pub struct FhirSearchParams { #[serde(rename = "_id")] pub id: Option, #[serde(rename = "_count")] pub count: Option, #[serde(rename = "_offset")] pub offset: Option, pub patient: Option, pub category: Option, pub code: Option, pub date: Option, pub name: Option, pub identifier: Option, pub status: Option, } // ── 患者访问范围辅助 ────────────────────────────────────────────────── /// 检查单个 patient_id 是否在 OAuth 客户端的允许范围内 fn enforce_patient_scope(fhir_ctx: &FhirAuthContext, patient_id: Uuid) -> Result<(), AppError> { match &fhir_ctx.allowed_patient_ids { Some(allowed) if !allowed.is_empty() => { if !allowed.iter().any(|id| id == &patient_id.to_string()) { tracing::warn!( client_id = %fhir_ctx.client_id, requested_patient = %patient_id, "FHIR 客户端尝试访问授权范围外的患者" ); return Err(AppError::Forbidden( "Access denied: patient not in allowed scope".into(), )); } } _ => { return Err(AppError::Forbidden( "OAuth client has no patient access configured".into(), )); } } Ok(()) } /// 解析 allowed_patient_ids 为 Uuid 列表 fn resolve_allowed_patient_uuids(fhir_ctx: &FhirAuthContext) -> Option> { fhir_ctx.allowed_patient_ids.as_ref().map(|ids| { ids.iter() .filter_map(|id| Uuid::parse_str(id).ok()) .collect() }) } // ── Patient ──────────────────────────────────────────────────────────── pub async fn search_patients( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.patient.list")?; let mut query = crate::entity::patient::Entity::find() .filter(crate::entity::patient::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::patient::Column::DeletedAt.is_null()); if let Some(ref id) = params.id { let uid = Uuid::parse_str(id).map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::patient::Column::Id.eq(uid)); } if let Some(ref name) = params.name { query = query.filter(crate::entity::patient::Column::Name.contains(name)); } if let Some(ref identifier) = params.identifier { query = query.filter(crate::entity::patient::Column::IdNumber.contains(identifier)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::patient::Column::Id.is_in(uuids)); } let limit = params.count.unwrap_or(20).min(100); let offset = params.offset.unwrap_or(0); let patients = query.limit(limit).offset(offset).all(&state.db).await?; let entries: Vec = patients .iter() .map(|p| serde_json::json!({"resource": converter::patient_to_fhir(p)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_patient( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.patient.list")?; enforce_patient_scope(&fhir_ctx, id)?; let patient = crate::entity::patient::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Patient not found".into()))?; if patient.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } Ok(Json(converter::patient_to_fhir(&patient))) } // ── Observation ──────────────────────────────────────────────────────── pub async fn search_observations( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.device-readings.list")?; let mut query = crate::entity::device_readings::Entity::find() .filter(crate::entity::device_readings::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::device_readings::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::device_readings::Column::PatientId.eq(uid)); } if let Some(ref code) = params.code && let Some(dt) = loinc_to_device_type(code) { query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt)); } if let Some(ref category) = params.category { let types = category_to_device_types(category); if !types.is_empty() { query = query.filter(crate::entity::device_readings::Column::DeviceType.is_in(types)); } } if let Some(ref date) = params.date { if let Some(after) = date.strip_prefix("gt") { if let Ok(dt) = after.parse::>() { query = query.filter(crate::entity::device_readings::Column::MeasuredAt.gt(dt)); } } else if let Some(before) = date.strip_prefix("lt") { if let Ok(dt) = before.parse::>() { query = query.filter(crate::entity::device_readings::Column::MeasuredAt.lt(dt)); } } else if let Ok(dt) = date.parse::>() { let start = dt.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = dt.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); query = query .filter(crate::entity::device_readings::Column::MeasuredAt.between(start, end)); } } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::device_readings::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let readings = query .order_by_desc(crate::entity::device_readings::Column::MeasuredAt) .limit(limit) .all(&state.db) .await?; let mut entries = Vec::new(); for reading in &readings { for obs in converter::device_reading_to_fhir_observations(reading) { entries.push(serde_json::json!({"resource": obs})); } } Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } // ── Device ───────────────────────────────────────────────────────────── pub async fn search_devices( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.devices.list")?; let mut query = crate::entity::patient_devices::Entity::find() .filter(crate::entity::patient_devices::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::patient_devices::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::patient_devices::Column::PatientId.eq(uid)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::patient_devices::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let devices = query.limit(limit).all(&state.db).await?; let entries: Vec = devices .iter() .map(|d| serde_json::json!({"resource": converter::patient_device_to_fhir(d)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_device( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.devices.list")?; let device = crate::entity::patient_devices::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Device not found".into()))?; if device.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } enforce_patient_scope(&fhir_ctx, device.patient_id)?; Ok(Json(converter::patient_device_to_fhir(&device))) } // ── Practitioner ─────────────────────────────────────────────────────── pub async fn search_practitioners( State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.doctor.list")?; let mut query = crate::entity::doctor_profile::Entity::find() .filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::doctor_profile::Column::DeletedAt.is_null()); if let Some(ref name) = params.name { query = query.filter(crate::entity::doctor_profile::Column::Name.contains(name)); } let limit = params.count.unwrap_or(50).min(200); let doctors = query.limit(limit).all(&state.db).await?; let entries: Vec = doctors .iter() .map(|d| serde_json::json!({"resource": converter::doctor_to_fhir(d)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_practitioner( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.doctor.list")?; let doctor = crate::entity::doctor_profile::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Practitioner not found".into()))?; if doctor.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } Ok(Json(converter::doctor_to_fhir(&doctor))) } // ── Appointment ──────────────────────────────────────────────────────── pub async fn search_appointments( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.appointment.list")?; let mut query = crate::entity::appointment::Entity::find() .filter(crate::entity::appointment::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::appointment::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::appointment::Column::PatientId.eq(uid)); } if let Some(ref status) = params.status { query = query.filter(crate::entity::appointment::Column::Status.eq(status)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::appointment::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let appointments = query .order_by_desc(crate::entity::appointment::Column::AppointmentDate) .limit(limit) .all(&state.db) .await?; let entries: Vec = appointments .iter() .map(|a| serde_json::json!({"resource": converter::appointment_to_fhir(a)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_appointment( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.appointment.list")?; let appointment = crate::entity::appointment::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Appointment not found".into()))?; if appointment.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } enforce_patient_scope(&fhir_ctx, appointment.patient_id)?; Ok(Json(converter::appointment_to_fhir(&appointment))) } // ── DiagnosticReport ─────────────────────────────────────────────────── pub async fn search_diagnostic_reports( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.health-data.list")?; let mut query = crate::entity::lab_report::Entity::find() .filter(crate::entity::lab_report::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::lab_report::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::lab_report::Column::PatientId.eq(uid)); } if let Some(ref code) = params.code { query = query.filter(crate::entity::lab_report::Column::ReportType.eq(code)); } if let Some(ref status) = params.status { query = query.filter(crate::entity::lab_report::Column::Status.eq(status)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::lab_report::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let reports = query .order_by_desc(crate::entity::lab_report::Column::ReportDate) .limit(limit) .all(&state.db) .await?; let entries: Vec = reports .iter() .map(|r| serde_json::json!({"resource": converter::lab_report_to_fhir(r)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_diagnostic_report( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.health-data.list")?; let report = crate::entity::lab_report::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("DiagnosticReport not found".into()))?; if report.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } enforce_patient_scope(&fhir_ctx, report.patient_id)?; Ok(Json(converter::lab_report_to_fhir(&report))) } // ── Encounter ────────────────────────────────────────────────────────── pub async fn search_encounters( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.consultation.list")?; let mut query = crate::entity::consultation_session::Entity::find() .filter(crate::entity::consultation_session::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::consultation_session::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::consultation_session::Column::PatientId.eq(uid)); } if let Some(ref status) = params.status { query = query.filter(crate::entity::consultation_session::Column::Status.eq(status)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::consultation_session::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let sessions = query .order_by_desc(crate::entity::consultation_session::Column::CreatedAt) .limit(limit) .all(&state.db) .await?; let entries: Vec = sessions .iter() .map(|s| serde_json::json!({"resource": converter::consultation_to_fhir(s)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_encounter( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.consultation.list")?; let session = crate::entity::consultation_session::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Encounter not found".into()))?; if session.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } enforce_patient_scope(&fhir_ctx, session.patient_id)?; Ok(Json(converter::consultation_to_fhir(&session))) } // ── Task ─────────────────────────────────────────────────────────────── pub async fn search_tasks( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Query(params): Query, ) -> Result { require_permission(&ctx, "health.follow-up.list")?; let mut query = crate::entity::follow_up_task::Entity::find() .filter(crate::entity::follow_up_task::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::follow_up_task::Column::DeletedAt.is_null()); if let Some(ref patient_id) = params.patient { let uid = Uuid::parse_str(patient_id) .map_err(|_| AppError::Validation("Invalid patient id".into()))?; query = query.filter(crate::entity::follow_up_task::Column::PatientId.eq(uid)); } if let Some(ref status) = params.status { query = query.filter(crate::entity::follow_up_task::Column::Status.eq(status)); } // 强制执行 allowed_patient_ids 范围 if let Some(uuids) = resolve_allowed_patient_uuids(&fhir_ctx) && !uuids.is_empty() { query = query.filter(crate::entity::follow_up_task::Column::PatientId.is_in(uuids)); } let limit = params.count.unwrap_or(50).min(200); let tasks = query .order_by_desc(crate::entity::follow_up_task::Column::PlannedDate) .limit(limit) .all(&state.db) .await?; let entries: Vec = tasks .iter() .map(|t| serde_json::json!({"resource": converter::follow_up_to_fhir(t)})) .collect(); Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "searchset", "total": entries.len(), "entry": entries, }))) } pub async fn get_task( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.follow-up.list")?; let task = crate::entity::follow_up_task::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Task not found".into()))?; if task.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } enforce_patient_scope(&fhir_ctx, task.patient_id)?; Ok(Json(converter::follow_up_to_fhir(&task))) } // ── $everything ──────────────────────────────────────────────────────── /// GET /fhir/R4/Patient/{id}/$everything pub async fn patient_everything( State(state): State, Extension(ctx): Extension, Extension(fhir_ctx): Extension, Path(id): Path, ) -> Result { require_permission(&ctx, "health.patient.list")?; enforce_patient_scope(&fhir_ctx, id)?; let patient = crate::entity::patient::Entity::find_by_id(id) .one(&state.db) .await? .ok_or_else(|| AppError::NotFound("Patient not found".into()))?; if patient.tenant_id != ctx.tenant_id { return Err(AppError::Forbidden("Access denied".into())); } let mut entries = Vec::new(); // 1. Patient entries.push(serde_json::json!({ "resource": converter::patient_to_fhir(&patient), "fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", id), })); // 2. Observations(设备读数) let readings = crate::entity::device_readings::Entity::find() .filter(crate::entity::device_readings::Column::PatientId.eq(id)) .filter(crate::entity::device_readings::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::device_readings::Column::DeletedAt.is_null()) .limit(200) .all(&state.db) .await?; for r in &readings { for obs in converter::device_reading_to_fhir_observations(r) { entries.push(serde_json::json!({"resource": obs})); } } // 3. Devices let devices = crate::entity::patient_devices::Entity::find() .filter(crate::entity::patient_devices::Column::PatientId.eq(id)) .filter(crate::entity::patient_devices::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::patient_devices::Column::DeletedAt.is_null()) .all(&state.db) .await?; for d in &devices { entries.push(serde_json::json!({"resource": converter::patient_device_to_fhir(d)})); } // 4. Encounters(咨询会话) let consultations = crate::entity::consultation_session::Entity::find() .filter(crate::entity::consultation_session::Column::PatientId.eq(id)) .filter(crate::entity::consultation_session::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::consultation_session::Column::DeletedAt.is_null()) .all(&state.db) .await?; for c in &consultations { entries.push(serde_json::json!({"resource": converter::consultation_to_fhir(c)})); } // 5. Appointments let appointments = crate::entity::appointment::Entity::find() .filter(crate::entity::appointment::Column::PatientId.eq(id)) .filter(crate::entity::appointment::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::appointment::Column::DeletedAt.is_null()) .all(&state.db) .await?; for a in &appointments { entries.push(serde_json::json!({"resource": converter::appointment_to_fhir(a)})); } // 6. Tasks(随访任务) let tasks = crate::entity::follow_up_task::Entity::find() .filter(crate::entity::follow_up_task::Column::PatientId.eq(id)) .filter(crate::entity::follow_up_task::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::follow_up_task::Column::DeletedAt.is_null()) .limit(50) .all(&state.db) .await?; for t in &tasks { entries.push(serde_json::json!({"resource": converter::follow_up_to_fhir(t)})); } // 7. DiagnosticReports(化验报告) let reports = crate::entity::lab_report::Entity::find() .filter(crate::entity::lab_report::Column::PatientId.eq(id)) .filter(crate::entity::lab_report::Column::TenantId.eq(ctx.tenant_id)) .filter(crate::entity::lab_report::Column::DeletedAt.is_null()) .limit(50) .all(&state.db) .await?; for r in &reports { entries.push(serde_json::json!({"resource": converter::lab_report_to_fhir(r)})); } Ok(Json(serde_json::json!({ "resourceType": "Bundle", "type": "collection", "total": entries.len(), "entry": entries, }))) } #[cfg(test)] mod tests { use super::*; use crate::oauth::middleware::FhirAuthContext; fn default_fhir_ctx() -> FhirAuthContext { FhirAuthContext { client_id: Uuid::now_v7(), tenant_id: Uuid::now_v7(), scopes: vec!["patient/*.read".to_string()], allowed_patient_ids: None, } } #[test] fn test_enforce_patient_scope_none_is_denied() { let fhir_ctx = FhirAuthContext { allowed_patient_ids: None, ..default_fhir_ctx() }; let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7()); assert!(result.is_err(), "None should deny all access"); } #[test] fn test_enforce_patient_scope_empty_vec_is_denied() { let fhir_ctx = FhirAuthContext { allowed_patient_ids: Some(vec![]), ..default_fhir_ctx() }; let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7()); assert!(result.is_err(), "Empty list should deny all access"); } #[test] fn test_enforce_patient_scope_allowed_access() { let patient_id = Uuid::now_v7(); let fhir_ctx = FhirAuthContext { allowed_patient_ids: Some(vec![patient_id.to_string()]), ..default_fhir_ctx() }; let result = enforce_patient_scope(&fhir_ctx, patient_id); assert!(result.is_ok(), "Patient in allowed list should succeed"); } #[test] fn test_enforce_patient_scope_wrong_patient_denied() { let fhir_ctx = FhirAuthContext { allowed_patient_ids: Some(vec![Uuid::now_v7().to_string()]), ..default_fhir_ctx() }; let result = enforce_patient_scope(&fhir_ctx, Uuid::now_v7()); assert!( result.is_err(), "Patient not in allowed list should be denied" ); } }