From 121f1c96f95cab60985c9ee7c23dfb3965838b1a Mon Sep 17 00:00:00 2001 From: dreamgenies Date: Mon, 30 Mar 2026 13:22:07 +0100 Subject: [PATCH] feat: implement fine-grained field-level access control --- contracts/patient-registry/src/lib.rs | 284 +++++++++++++++++++------ contracts/patient-registry/src/test.rs | 210 +++++++++++++----- 2 files changed, 381 insertions(+), 113 deletions(-) diff --git a/contracts/patient-registry/src/lib.rs b/contracts/patient-registry/src/lib.rs index b95e590..f67bf60 100644 --- a/contracts/patient-registry/src/lib.rs +++ b/contracts/patient-registry/src/lib.rs @@ -6,8 +6,8 @@ use soroban_sdk::{ xdr::ToXdr, Address, Bytes, BytesN, Env, Map, String, Symbol, Vec, }; -pub mod validation; pub mod merkle; +pub mod validation; pub const NEW_RECORD_TOPIC: &str = "new_record"; // ===================================================== @@ -104,6 +104,8 @@ pub enum DataKey { PatientRecordIds(Address), /// Individual record data keyed by global record ID. MedicalRecord(u64), + /// Field-level access mask keyed by (patient, grantee, record_id). + FieldAccess(Address, Address, u64), /// Platform-wide secondary index: record_type → Vec. GlobalTypeIndex(Symbol), /// Soft-delete tombstone for a record (value: timestamp of deletion). @@ -145,6 +147,24 @@ pub struct MedicalRecord { pub record_type: Symbol, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FieldPermission { + RecordType, + IpfsHash, + CreatedAt, + CreatedBy, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PartialRecord { + pub record_type: Option, + pub ipfs_hash: Option, + pub created_at: Option, + pub created_by: Option
, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RecordVersion { @@ -252,6 +272,60 @@ fn require_record_access(env: &Env, patient: &Address, caller: &Address) { panic!("Caller not authorized to view records"); } +const FIELD_RECORD_TYPE: u32 = 1 << 0; +const FIELD_IPFS_HASH: u32 = 1 << 1; +const FIELD_CREATED_AT: u32 = 1 << 2; +const FIELD_CREATED_BY: u32 = 1 << 3; +const FIELD_ALL: u32 = FIELD_RECORD_TYPE | FIELD_IPFS_HASH | FIELD_CREATED_AT | FIELD_CREATED_BY; + +fn field_permission_mask(fields: Vec) -> u32 { + let mut mask = 0u32; + for field in fields.iter() { + mask |= match field { + FieldPermission::RecordType => FIELD_RECORD_TYPE, + FieldPermission::IpfsHash => FIELD_IPFS_HASH, + FieldPermission::CreatedAt => FIELD_CREATED_AT, + FieldPermission::CreatedBy => FIELD_CREATED_BY, + }; + } + mask +} + +fn empty_partial_record() -> PartialRecord { + PartialRecord { + record_type: None, + ipfs_hash: None, + created_at: None, + created_by: None, + } +} + +fn build_partial_record(record_data: &RecordData, mask: u32) -> PartialRecord { + let created = record_data.history.get(0); + PartialRecord { + record_type: if (mask & FIELD_RECORD_TYPE) != 0 { + Some(record_data.record_type.clone()) + } else { + None + }, + ipfs_hash: if (mask & FIELD_IPFS_HASH) != 0 { + Some(record_data.current_ipfs.clone()) + } else { + None + }, + created_at: if (mask & FIELD_CREATED_AT) != 0 { + created.as_ref().map(|version| version.updated_at) + } else { + None + }, + created_by: if (mask & FIELD_CREATED_BY) != 0 { + created.map(|version| version.updated_by) + } else { + None + }, + } +} + #[contract] pub struct MedicalRegistry; @@ -270,9 +344,15 @@ impl MedicalRegistry { env.storage().instance().set(&DataKey::FeeToken, &fee_token); env.storage().instance().set(&DataKey::RecordFee, &0i128); env.storage().instance().set(&DataKey::TotalPatients, &0u64); - env.storage().instance().set(&DataKey::TotalRecordsCreated, &0u64); - env.storage().instance().set(&DataKey::TotalProviders, &0u64); - env.storage().instance().set(&DataKey::TotalAccessGrants, &0u64); + env.storage() + .instance() + .set(&DataKey::TotalRecordsCreated, &0u64); + env.storage() + .instance() + .set(&DataKey::TotalProviders, &0u64); + env.storage() + .instance() + .set(&DataKey::TotalAccessGrants, &0u64); env.storage().instance().set(&DataKey::RecordCounter, &0u64); } @@ -820,6 +900,43 @@ impl MedicalRegistry { env.storage().persistent().set(&key, &map); } + pub fn grant_field_access( + env: Env, + patient: Address, + grantee: Address, + record_id: u64, + fields: Vec, + ) -> Result<(), ContractError> { + Self::require_not_frozen(&env); + patient.require_auth(); + Self::require_not_on_hold(&env, &patient); + + let record_data: RecordData = env + .storage() + .persistent() + .get(&DataKey::MedicalRecord(record_id)) + .ok_or(ContractError::NotFound)?; + if record_data.patient != patient { + return Err(ContractError::NotAuthorized); + } + + let access_key = DataKey::AuthorizedDoctors(patient.clone()); + let access_map: Map = env + .storage() + .persistent() + .get(&access_key) + .unwrap_or(Map::new(&env)); + if !access_map.contains_key(grantee.clone()) { + return Err(ContractError::NotAuthorized); + } + + let mask = field_permission_mask(fields); + env.storage() + .persistent() + .set(&DataKey::FieldAccess(patient, grantee, record_id), &mask); + Ok(()) + } + pub fn revoke_access(env: Env, patient: Address, caller: Address, doctor: Address) { Self::require_not_frozen(&env); require_patient_or_guardian(&env, &patient, &caller); @@ -946,12 +1063,7 @@ impl MedicalRegistry { }; let counter_key = DataKey::RecordCounter; - let record_id: u64 = env - .storage() - .persistent() - .get(&counter_key) - .unwrap_or(0u64) - + 1; + let record_id: u64 = env.storage().persistent().get(&counter_key).unwrap_or(0u64) + 1; env.storage().persistent().set(&counter_key, &record_id); let timestamp = env.ledger().timestamp(); @@ -1015,11 +1127,9 @@ impl MedicalRegistry { record_id, }); env.storage().persistent().set(&idx_key, &type_index); - env.storage().persistent().extend_ttl( - &idx_key, - LEDGER_THRESHOLD, - LEDGER_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&idx_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); // ───────────────────────────────────────────────────────────────────── // TTL bumps for per-patient and per-record keys. @@ -1034,7 +1144,11 @@ impl MedicalRegistry { .extend_ttl(&ids_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); env.events().publish( - (Symbol::new(&env, NEW_RECORD_TOPIC), patient.clone(), doctor.clone()), + ( + Symbol::new(&env, NEW_RECORD_TOPIC), + patient.clone(), + doctor.clone(), + ), (record_id, record_type, timestamp), ); @@ -1072,9 +1186,11 @@ impl MedicalRegistry { // Also bump the patient record itself let patient_key = DataKey::Patient(patient.clone()); if env.storage().persistent().has(&patient_key) { - env.storage() - .persistent() - .extend_ttl(&patient_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); + env.storage().persistent().extend_ttl( + &patient_key, + LEDGER_THRESHOLD, + LEDGER_BUMP_AMOUNT, + ); } env.storage() @@ -1129,9 +1245,11 @@ impl MedicalRegistry { .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); } if env.storage().persistent().has(&patient_key) { - env.storage() - .persistent() - .extend_ttl(&patient_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); + env.storage().persistent().extend_ttl( + &patient_key, + LEDGER_THRESHOLD, + LEDGER_BUMP_AMOUNT, + ); } let mut latest = records.get(0).unwrap().clone(); @@ -1148,11 +1266,7 @@ impl MedicalRegistry { /// If no root was persisted yet, recomputes from `PatientRecordIds` (or empty sentinel). pub fn get_merkle_root(env: Env, patient: Address) -> BytesN<32> { let key = DataKey::MerkleRoot(patient.clone()); - if let Some(root) = env - .storage() - .persistent() - .get::>(&key) - { + if let Some(root) = env.storage().persistent().get::>(&key) { root } else { let ids_key = DataKey::PatientRecordIds(patient); @@ -1223,11 +1337,8 @@ impl MedicalRegistry { .persistent() .extend_ttl(&record_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); - env.events() - .publish(( - symbol_short!("rec_upd"), - (patient.clone(), caller.clone()), - ), + env.events().publish( + (symbol_short!("rec_upd"), (patient.clone(), caller.clone())), record_id, ); @@ -1253,6 +1364,50 @@ impl MedicalRegistry { Ok(record_data.history) } + pub fn get_record_fields( + env: Env, + patient: Address, + caller: Address, + record_id: u64, + ) -> PartialRecord { + caller.require_auth(); + + let record_data: RecordData = match env + .storage() + .persistent() + .get(&DataKey::MedicalRecord(record_id)) + { + Some(record) => record, + None => return empty_partial_record(), + }; + if record_data.patient != patient { + return empty_partial_record(); + } + + let guardian_key = DataKey::Guardian(patient.clone()); + let guardian_opt: Option
= env.storage().persistent().get(&guardian_key); + let mask = if caller == patient || guardian_opt.as_ref() == Some(&caller) { + FIELD_ALL + } else { + let access_key = DataKey::AuthorizedDoctors(patient.clone()); + let access_map: Map = env + .storage() + .persistent() + .get(&access_key) + .unwrap_or(Map::new(&env)); + if !access_map.contains_key(caller.clone()) { + return empty_partial_record(); + } + + env.storage() + .persistent() + .get(&DataKey::FieldAccess(patient, caller, record_id)) + .unwrap_or(0u32) + }; + + build_partial_record(&record_data, mask) + } + pub fn get_records_by_type( env: Env, patient: Address, @@ -1271,7 +1426,11 @@ impl MedicalRegistry { let mut filtered = Vec::new(&env); for id in record_ids.iter() { let record_id: u64 = id.into(); - if let Some(record_data) = env.storage().persistent().get::(&DataKey::MedicalRecord(record_id)) { + if let Some(record_data) = env + .storage() + .persistent() + .get::(&DataKey::MedicalRecord(record_id)) + { if record_data.record_type == record_type { // Map to MedicalRecord for compatibility let mr = MedicalRecord { @@ -1280,7 +1439,12 @@ impl MedicalRegistry { .history .get(0) .map(|v| v.updated_by.clone()) - .unwrap_or_else(|| Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4")), + .unwrap_or_else(|| { + Address::from_str( + &env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + ) + }), record_hash: record_data.current_ipfs.clone(), description: record_data.description.clone(), timestamp: record_data @@ -1451,11 +1615,7 @@ impl MedicalRegistry { // Increment per-patient nonce. let nonce_key = DataKey::ShareNonce(patient.clone()); - let nonce: u64 = env - .storage() - .persistent() - .get(&nonce_key) - .unwrap_or(0u64); + let nonce: u64 = env.storage().persistent().get(&nonce_key).unwrap_or(0u64); let next_nonce = nonce + 1; env.storage().persistent().set(&nonce_key, &next_nonce); @@ -1492,10 +1652,7 @@ impl MedicalRegistry { /// Any address may call this function. The token is validated for expiry /// and remaining uses; uses_remaining is decremented on success and the /// token is removed when it reaches zero. - pub fn use_share_link( - env: Env, - token: BytesN<32>, - ) -> Result { + pub fn use_share_link(env: Env, token: BytesN<32>) -> Result { let link_key = DataKey::ShareLink(token.clone()); let mut link: ShareLinkData = env .storage() @@ -1552,7 +1709,11 @@ impl MedicalRegistry { /// Callable by the owning patient, their guardian, or an authorized doctor. /// After deletion the record data is retained for audit purposes but will no /// longer appear in index queries. - pub fn soft_delete_record(env: Env, record_id: u64, caller: Address) -> Result<(), ContractError> { + pub fn soft_delete_record( + env: Env, + record_id: u64, + caller: Address, + ) -> Result<(), ContractError> { Self::require_not_frozen(&env); let record_key = DataKey::MedicalRecord(record_id); @@ -1567,7 +1728,11 @@ impl MedicalRegistry { require_record_access(&env, &patient, &caller); // Guard: already deleted? - if env.storage().persistent().has(&DataKey::DeletedRecord(record_id)) { + if env + .storage() + .persistent() + .has(&DataKey::DeletedRecord(record_id)) + { panic!("Record already deleted"); } @@ -1593,11 +1758,9 @@ impl MedicalRegistry { } } env.storage().persistent().set(&idx_key, &updated); - env.storage().persistent().extend_ttl( - &idx_key, - LEDGER_THRESHOLD, - LEDGER_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&idx_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); // ───────────────────────────────────────────────────────────────────── env.events().publish( @@ -1625,11 +1788,9 @@ impl MedicalRegistry { .unwrap_or(Vec::new(&env)); if env.storage().persistent().has(&idx_key) { - env.storage().persistent().extend_ttl( - &idx_key, - LEDGER_THRESHOLD, - LEDGER_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&idx_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); } Ok(index) @@ -1639,10 +1800,7 @@ impl MedicalRegistry { /// across all patients. /// /// **Admin-only.** Non-admins receive `NotAuthorized`. - pub fn get_global_type_count( - env: Env, - record_type: Symbol, - ) -> Result { + pub fn get_global_type_count(env: Env, record_type: Symbol) -> Result { Self::require_admin(&env); let idx_key = DataKey::GlobalTypeIndex(record_type); @@ -1715,11 +1873,9 @@ impl MedicalRegistry { let root = merkle::compute_merkle_root(env, ids); let root_key = DataKey::MerkleRoot(patient.clone()); env.storage().persistent().set(&root_key, &root); - env.storage().persistent().extend_ttl( - &root_key, - LEDGER_THRESHOLD, - LEDGER_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&root_key, LEDGER_THRESHOLD, LEDGER_BUMP_AMOUNT); } /// Bump TTL for all critical persistent keys belonging to a patient. diff --git a/contracts/patient-registry/src/test.rs b/contracts/patient-registry/src/test.rs index a29e305..b71a307 100644 --- a/contracts/patient-registry/src/test.rs +++ b/contracts/patient-registry/src/test.rs @@ -195,7 +195,10 @@ fn test_analytics_counters_admin_only() { address: &attacker, invoke: &inv1, }; - assert!(client.mock_auths(&[a1]).try_get_total_records_created().is_err()); + assert!(client + .mock_auths(&[a1]) + .try_get_total_records_created() + .is_err()); let inv2 = MockAuthInvoke { contract: &contract_id, @@ -219,7 +222,10 @@ fn test_analytics_counters_admin_only() { address: &attacker, invoke: &inv3, }; - assert!(client.mock_auths(&[a3]).try_get_total_access_grants().is_err()); + assert!(client + .mock_auths(&[a3]) + .try_get_total_access_grants() + .is_err()); } #[test] @@ -230,7 +236,13 @@ fn test_total_records_created_increments_on_add_record() { let before = client.get_total_records_created(); assert_eq!(before, 0); - client.add_medical_record(&patient, &doctor, &make_cid_v1(&env, 11), &String::from_str(&env, "Lab"), &Symbol::new(&env, "LAB")); + client.add_medical_record( + &patient, + &doctor, + &make_cid_v1(&env, 11), + &String::from_str(&env, "Lab"), + &Symbol::new(&env, "LAB"), + ); let after = client.get_total_records_created(); assert_eq!(after, 1); } @@ -253,7 +265,12 @@ fn test_total_providers_increment_on_register() { assert_eq!(client.get_total_providers(), 0); - client.register_doctor(&doctor, &String::from_str(&env, "Dr"), &String::from_str(&env, "Spec"), &make_cid_v1(&env, 2)); + client.register_doctor( + &doctor, + &String::from_str(&env, "Dr"), + &String::from_str(&env, "Spec"), + &make_cid_v1(&env, 2), + ); assert_eq!(client.get_total_providers(), 1); client.register_institution(&institution); @@ -402,7 +419,12 @@ fn test_grant_access_and_add_medical_record() { let treasury = Address::generate(&env); let fee_token = Address::generate(&env); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&v1); client.acknowledge_consent(&patient, &patient, &v1); client.grant_access(&patient, &patient, &doctor); @@ -579,7 +601,12 @@ fn test_add_medical_record_rejects_invalid_cid() { env.mock_all_auths(); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&version); client.acknowledge_consent(&patient, &patient, &version); client.grant_access(&patient, &patient, &doctor); @@ -1153,7 +1180,12 @@ fn test_add_record_blocked_without_consent() { let treasury = Address::generate(&env); let fee_token = Address::generate(&env); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&make_version(&env, 1)); // Patient never acknowledges client.grant_access(&patient, &patient, &doctor); @@ -1180,7 +1212,12 @@ fn test_add_record_allowed_after_consent() { let treasury = Address::generate(&env); let fee_token = Address::generate(&env); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&v1); client.acknowledge_consent(&patient, &patient, &v1); client.grant_access(&patient, &patient, &doctor); @@ -1211,7 +1248,12 @@ fn test_add_record_blocked_after_new_version() { let treasury = Address::generate(&env); let fee_token = Address::generate(&env); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&v1); client.acknowledge_consent(&patient, &patient, &v1); client.grant_access(&patient, &patient, &doctor); @@ -1337,7 +1379,12 @@ fn test_guardian_enables_record_write() { let guardian = Address::generate(&env); let doctor = Address::generate(&env); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.assign_guardian(&patient, &guardian); client.acknowledge_consent(&patient, &guardian, &v1); client.grant_access(&patient, &guardian, &doctor); @@ -1587,7 +1634,12 @@ fn setup_with_fee( env.mock_all_auths(); client.initialize(&admin, &treasury, &token_id); - client.register_patient(&patient, &String::from_str(env, "Test Patient"), &631152000, &String::from_str(env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(env, "Test Patient"), + &631152000, + &String::from_str(env, "ipfs://test"), + ); client.publish_consent_version(&v1); client.acknowledge_consent(&patient, &patient, &v1); client.grant_access(&patient, &patient, &doctor); @@ -1726,7 +1778,13 @@ fn make_ledger_info(sequence: u32, timestamp: u64) -> soroban_sdk::testutils::Le /// Shared setup for TTL tests: initialized contract + registered patient with consent + doctor. fn setup_for_ttl( env: &Env, -) -> (MedicalRegistryClient<'_>, Address, Address, Address, BytesN<32>) { +) -> ( + MedicalRegistryClient<'_>, + Address, + Address, + Address, + BytesN<32>, +) { let contract_id = env.register(MedicalRegistry, ()); let client = MedicalRegistryClient::new(env, &contract_id); let admin = Address::generate(env); @@ -1814,7 +1872,10 @@ fn test_get_records_by_type_returns_matching_records() { let results = client.get_records_by_type(&patient, &patient, &Symbol::new(&env, "VISIT")); assert_eq!(results.len(), 1); - assert_eq!(results.get(0).unwrap().description, String::from_str(&env, "Checkup")); + assert_eq!( + results.get(0).unwrap().description, + String::from_str(&env, "Checkup") + ); } #[test] @@ -1958,7 +2019,12 @@ fn test_get_latest_record_returns_error_if_no_records() { env.mock_all_auths(); client.initialize(&admin, &treasury, &fee_token); client.publish_consent_version(&v1); - client.register_patient(&patient, &String::from_str(&env, "NoRecords"), &631152000, &String::from_str(&env, "ipfs://none")); + client.register_patient( + &patient, + &String::from_str(&env, "NoRecords"), + &631152000, + &String::from_str(&env, "ipfs://none"), + ); client.acknowledge_consent(&patient, &patient, &v1); let result = client.try_get_latest_record(&patient, &patient); @@ -2056,8 +2122,7 @@ fn test_get_records_by_type_returns_empty_when_no_records_at_all() { let (client, patient, _doctor) = setup_for_filter(&env); // Patient registered but no records added yet - let result = - client.get_records_by_type(&patient, &patient, &Symbol::new(&env, "LAB")); + let result = client.get_records_by_type(&patient, &patient, &Symbol::new(&env, "LAB")); assert_eq!(result.len(), 0); } @@ -2100,6 +2165,59 @@ fn test_get_records_by_ids_unauthorized_caller_rejected() { assert!(result.is_err()); } +#[test] +fn test_get_record_fields_full_access_for_patient() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let (_admin, patient, doctor, client) = setup_with_record(&env); + + let partial = client.get_record_fields(&patient, &patient, &1u64); + + assert_eq!(partial.record_type, Some(Symbol::new(&env, "LAB"))); + assert_eq!(partial.ipfs_hash, Some(make_cid_v1(&env, 1))); + assert_eq!(partial.created_at, Some(1_000)); + assert_eq!(partial.created_by, Some(doctor)); +} + +#[test] +fn test_get_record_fields_partial_access_for_grantee() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(2_000); + + let (_admin, patient, doctor, client) = setup_with_record(&env); + let mut fields = Vec::new(&env); + fields.push_back(FieldPermission::RecordType); + fields.push_back(FieldPermission::CreatedAt); + + client.grant_field_access(&patient, &doctor, &1u64, &fields); + + let partial = client.get_record_fields(&patient, &doctor, &1u64); + + assert_eq!(partial.record_type, Some(Symbol::new(&env, "LAB"))); + assert_eq!(partial.created_at, Some(2_000)); + assert_eq!(partial.ipfs_hash, None); + assert_eq!(partial.created_by, None); +} + +#[test] +fn test_get_record_fields_returns_none_when_no_access() { + let env = Env::default(); + env.mock_all_auths(); + + let (_admin, patient, _doctor, client) = setup_with_record(&env); + let stranger = Address::generate(&env); + + let partial = client.get_record_fields(&patient, &stranger, &1u64); + + assert_eq!(partial.record_type, None); + assert_eq!(partial.ipfs_hash, None); + assert_eq!(partial.created_at, None); + assert_eq!(partial.created_by, None); +} + /// ------------------------------------------------ /// PROVIDER-TO-PATIENT RECORD NOTIFICATION EVENT TESTS /// ------------------------------------------------ @@ -2124,12 +2242,8 @@ fn test_new_record_event_emitted_on_add_record() { let mut found = false; for (_contract_id, topics, data) in events.iter() { - let expected_topics_val: soroban_sdk::Vec = ( - new_record_topic.clone(), - patient.clone(), - doctor.clone(), - ) - .into_val(&env); + let expected_topics_val: soroban_sdk::Vec = + (new_record_topic.clone(), patient.clone(), doctor.clone()).into_val(&env); if topics == expected_topics_val { let actual_data: (u64, Symbol, u64) = data.into_val(&env); assert_eq!( @@ -2278,12 +2392,8 @@ fn test_new_record_event_contains_correct_timestamp() { let mut found = false; for (_contract_id, topics, data) in events.iter() { - let expected_topics_val: soroban_sdk::Vec = ( - new_record_topic.clone(), - patient.clone(), - doctor.clone(), - ) - .into_val(&env); + let expected_topics_val: soroban_sdk::Vec = + (new_record_topic.clone(), patient.clone(), doctor.clone()).into_val(&env); if topics == expected_topics_val { let actual_data: (u64, Symbol, u64) = data.into_val(&env); assert_eq!( @@ -2342,7 +2452,6 @@ fn test_new_record_event_not_emitted_on_unauthorized_add() { } } - // ===================================================== // CONTRACT FREEZE TESTS // ===================================================== @@ -2612,8 +2721,7 @@ fn test_create_share_link_returns_token() { let (_admin, patient, _doctor, client) = setup_with_record(&env); - let token = client - .create_share_link(&patient, &0u64, &1u32, &2000u64); + let token = client.create_share_link(&patient, &0u64, &1u32, &2000u64); // Token is a 32-byte hash assert_eq!(token.len(), 32); @@ -2627,8 +2735,7 @@ fn test_single_use_link_works_once() { let (_admin, patient, _doctor, client) = setup_with_record(&env); - let token = client - .create_share_link(&patient, &0u64, &1u32, &2000u64); + let token = client.create_share_link(&patient, &0u64, &1u32, &2000u64); // First use succeeds let record = client.use_share_link(&token); @@ -2647,8 +2754,7 @@ fn test_multi_use_link_decrements_and_exhausts() { let (_admin, patient, _doctor, client) = setup_with_record(&env); - let token = client - .create_share_link(&patient, &0u64, &3u32, &9000u64); + let token = client.create_share_link(&patient, &0u64, &3u32, &9000u64); // Three successful uses for _ in 0..3 { @@ -2670,8 +2776,7 @@ fn test_expired_token_returns_invalid_token() { let (_admin, patient, _doctor, client) = setup_with_record(&env); // expires_at = 1500 - let token = client - .create_share_link(&patient, &0u64, &5u32, &1500u64); + let token = client.create_share_link(&patient, &0u64, &5u32, &1500u64); // Advance time past expiry env.ledger().set_timestamp(1501); @@ -2739,10 +2844,8 @@ fn test_two_links_for_same_record_are_independent() { let (_admin, patient, _doctor, client) = setup_with_record(&env); - let token_a = client - .create_share_link(&patient, &0u64, &1u32, &2000u64); - let token_b = client - .create_share_link(&patient, &0u64, &2u32, &2000u64); + let token_a = client.create_share_link(&patient, &0u64, &1u32, &2000u64); + let token_b = client.create_share_link(&patient, &0u64, &2u32, &2000u64); // Tokens must differ (different nonces) assert_ne!(token_a, token_b); @@ -2776,7 +2879,12 @@ fn test_only_patient_can_create_share_link() { env.mock_all_auths(); client.initialize(&admin, &treasury, &fee_token); - client.register_patient(&patient, &String::from_str(&env, "Test Patient"), &631152000, &String::from_str(&env, "ipfs://test")); + client.register_patient( + &patient, + &String::from_str(&env, "Test Patient"), + &631152000, + &String::from_str(&env, "ipfs://test"), + ); client.publish_consent_version(&v1); client.acknowledge_consent(&patient, &patient, &v1); client.grant_access(&patient, &patient, &doctor); @@ -2806,14 +2914,11 @@ fn test_only_patient_can_create_share_link() { assert!(result.is_err()); } - // ------------------------------------------------ // DEREGISTRATION TESTS // ------------------------------------------------ -fn setup_for_dereg( - env: &Env, -) -> (MedicalRegistryClient<'_>, Address, Address, Address) { +fn setup_for_dereg(env: &Env) -> (MedicalRegistryClient<'_>, Address, Address, Address) { let contract_id = env.register(MedicalRegistry, ()); let client = MedicalRegistryClient::new(env, &contract_id); let admin = Address::generate(env); @@ -3034,7 +3139,11 @@ fn build_proof(env: &Env, ids: &Vec, target_id: u64) -> Vec> { } let sibling_pos = if cur_pos % 2 == 0 { - if cur_pos + 1 < cur_len { cur_pos + 1 } else { cur_pos } + if cur_pos + 1 < cur_len { + cur_pos + 1 + } else { + cur_pos + } } else { cur_pos - 1 }; @@ -3080,8 +3189,11 @@ fn test_merkle_root_two_records() { let id0 = ids.get(0).unwrap(); let id1 = ids.get(1).unwrap(); - let expected = - merkle::hash_pair(&env, merkle::hash_leaf(&env, id0), merkle::hash_leaf(&env, id1)); + let expected = merkle::hash_pair( + &env, + merkle::hash_leaf(&env, id0), + merkle::hash_leaf(&env, id1), + ); assert_eq!(client.get_merkle_root(&patient), expected); }