From e3e99927f2ce5caf37cf2ae283d2e3b2cb06c063 Mon Sep 17 00:00:00 2001 From: JACOB STANLEY Date: Wed, 17 Jun 2026 21:05:14 +0100 Subject: [PATCH 1/3] https://github.com/Menjay7/Remitwise-Contracts.git --- bill_payments/src/lib.rs | 144 +++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 12 deletions(-) diff --git a/bill_payments/src/lib.rs b/bill_payments/src/lib.rs index 216f6f19..c29a912a 100644 --- a/bill_payments/src/lib.rs +++ b/bill_payments/src/lib.rs @@ -77,6 +77,7 @@ const STORAGE_UNPAID_TOTALS: Symbol = symbol_short!("UNPD_TOT"); const STORAGE_EXT_REF_IDX: Symbol = symbol_short!("EXTRIDX"); const STORAGE_OWNER_INDEX: Symbol = symbol_short!("OWN_IDX"); const STORAGE_ARCH_INDEX: Symbol = symbol_short!("ARCH_IDX"); +const STORAGE_CURRENCY_INDEX: Symbol = symbol_short!("CUR_IDX"); const ARCH_IDX_KEY: Symbol = STORAGE_ARCH_INDEX; #[contracterror] @@ -358,6 +359,104 @@ impl BillPayments { env.storage().instance().set(&STORAGE_ARCH_INDEX, &idx); } + // ----------------------------------------------------------------------- + // Currency-index helpers + // ----------------------------------------------------------------------- + + /// Load the currency index: Map<(Address, String), Vec> + /// Maps (owner, currency) pairs to their bill IDs in ascending order + fn get_currency_index(env: &Env) -> Map<(Address, String), Vec> { + env.storage() + .instance() + .get(&STORAGE_CURRENCY_INDEX) + .unwrap_or_else(|| Map::new(env)) + } + + fn save_currency_index(env: &Env, idx: &Map<(Address, String), Vec>) { + env.storage().instance().set(&STORAGE_CURRENCY_INDEX, idx); + } + + /// Get bill IDs for a specific owner and currency + fn get_bills_by_owner_currency(env: &Env, owner: &Address, currency: &String) -> Vec { + let idx = Self::get_currency_index(env); + idx.get((owner.clone(), currency.clone())).unwrap_or_else(|| Vec::new(env)) + } + + /// Add a bill ID to the currency index for (owner, currency) + fn index_add_currency(env: &Env, owner: &Address, currency: &String, bill_id: u32) { + let mut idx = Self::get_currency_index(env); + let key = (owner.clone(), currency.clone()); + let mut ids = idx.get(key.clone()).unwrap_or_else(|| Vec::new(env)); + + // Insert in ascending order + let mut new_ids: Vec = Vec::new(env); + let mut inserted = false; + for id in ids.iter() { + if !inserted { + if bill_id == id { + inserted = true; + } else if bill_id < id { + new_ids.push_back(bill_id); + inserted = true; + } + } + new_ids.push_back(id); + } + if !inserted { + new_ids.push_back(bill_id); + } + + idx.set(key, new_ids); + Self::save_currency_index(env, &idx); + } + + /// Remove a bill ID from the currency index for (owner, currency) + fn index_remove_currency(env: &Env, owner: &Address, currency: &String, bill_id: u32) { + let mut idx = Self::get_currency_index(env); + let key = (owner.clone(), currency.clone()); + if let Some(ids) = idx.get(key.clone()) { + let mut new_ids: Vec = Vec::new(env); + for id in ids.iter() { + if id != bill_id { + new_ids.push_back(id); + } + } + if new_ids.is_empty() { + idx.remove(key); + } else { + idx.set(key, new_ids); + } + Self::save_currency_index(env, &idx); + } + } + + /// Remove multiple bill IDs from the currency index for (owner, currency) + fn index_remove_currency_batch(env: &Env, owner: &Address, currency: &String, bill_ids: &Vec) { + let mut idx = Self::get_currency_index(env); + let key = (owner.clone(), currency.clone()); + if let Some(ids) = idx.get(key.clone()) { + let mut new_ids: Vec = Vec::new(env); + for id in ids.iter() { + let mut removed = false; + for b_id in bill_ids.iter() { + if id == b_id { + removed = true; + break; + } + } + if !removed { + new_ids.push_back(id); + } + } + if new_ids.is_empty() { + idx.remove(key); + } else { + idx.set(key, new_ids); + } + Self::save_currency_index(env, &idx); + } + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- @@ -929,6 +1028,7 @@ impl BillPayments { }; let bill_owner = bill.owner.clone(); + let bill_currency = bill.currency.clone(); let bill_ext_ref = bill.external_ref.clone(); bills.set(next_id, bill); env.storage() @@ -939,6 +1039,8 @@ impl BillPayments { .set(&symbol_short!("NEXT_ID"), &next_id); // Update owner index Self::index_add_active(&env, &bill_owner, next_id); + // Update currency index + Self::index_add_currency(&env, &bill_owner, &bill_currency, next_id); Self::adjust_unpaid_total(&env, &bill_owner, amount); // Emit event for audit trail @@ -1046,6 +1148,8 @@ impl BillPayments { .set(&symbol_short!("NEXT_ID"), &next_id); // Update owner index for the newly created recurring bill Self::index_add_active(&env, &caller, next_id); + // Update currency index for the newly created recurring bill + Self::index_add_currency(&env, &caller, &bill.currency, next_id); } let paid_amount = bill.amount; @@ -1684,6 +1788,7 @@ impl BillPayments { } let removed_unpaid_amount = if bill.paid { 0 } else { bill.amount }; + let bill_currency = bill.currency.clone(); bills.remove(bill_id); env.storage() .instance() @@ -1693,6 +1798,8 @@ impl BillPayments { } // Remove from owner index Self::index_remove_active(&env, &caller, bill_id); + // Remove from currency index + Self::index_remove_currency(&env, &caller, &bill_currency, bill_id); RemitwiseEvents::emit( &env, EventCategory::State, @@ -1735,6 +1842,7 @@ impl BillPayments { let mut archived_count = 0u32; let mut to_remove: Vec = Vec::new(&env); let mut owner_to_archived: Map> = Map::new(&env); + let mut owner_currency_to_removed: Map<(Address, String), Vec> = Map::new(&env); for (id, bill) in bills.iter() { if let Some(paid_at) = bill.paid_at { @@ -1763,6 +1871,14 @@ impl BillPayments { list.push_back(id); owner_to_archived.set(bill.owner.clone(), list); + // Track currency for index removal + let currency_key = (bill.owner.clone(), bill.currency.clone()); + let mut currency_list = owner_currency_to_removed + .get(currency_key.clone()) + .unwrap_or_else(|| Vec::new(&env)); + currency_list.push_back(id); + owner_currency_to_removed.set(currency_key, currency_list); + to_remove.push_back(id); archived_count += 1; } @@ -1786,6 +1902,11 @@ impl BillPayments { Self::index_add_archived_batch(&env, &owner, &ids); } + // Update currency indexes in batch per (owner, currency) + for ((owner, currency), ids) in owner_currency_to_removed.iter() { + Self::index_remove_currency_batch(&env, &owner, ¤cy, &ids); + } + Self::extend_archive_ttl(&env); Self::update_storage_stats(&env); @@ -1849,6 +1970,8 @@ impl BillPayments { Self::index_remove_archived(&env, &caller, bill_id); Self::index_add_active(&env, &caller, bill_id); + // Add back to currency index + Self::index_add_currency(&env, &caller, &archived_bill.currency, bill_id); env.storage() .instance() @@ -1998,6 +2121,8 @@ impl BillPayments { bills.set(next_id, next_bill); // Update owner index for the newly spawned recurring bill Self::index_add_active(&env, &caller, next_id); + // Update currency index for the newly spawned recurring bill + Self::index_add_currency(&env, &caller, &bill.currency, next_id); } else { unpaid_delta = unpaid_delta.saturating_sub(amount); } @@ -2119,20 +2244,17 @@ impl BillPayments { .get(&symbol_short!("BILLS")) .unwrap_or_else(|| Map::new(&env)); - // Use the owner index for O(owner_bills) traversal instead of O(NEXT_ID). - let owner_ids = Self::get_owner_bills(&env, &owner); + // Use the currency index for O(owner_currency_bills) traversal instead of O(owner_bills). + let currency_ids = Self::get_bills_by_owner_currency(&env, &owner, &normalized_currency); let mut staging: Vec<(u32, Bill)> = Vec::new(&env); - for id in owner_ids.iter() { + for id in currency_ids.iter() { if id <= cursor { continue; } let Some(bill) = bills.get(id) else { continue; }; - if bill.currency != normalized_currency { - continue; - } staging.push_back((id, bill)); if staging.len() > limit { break; @@ -2182,20 +2304,18 @@ impl BillPayments { .get(&symbol_short!("BILLS")) .unwrap_or_else(|| Map::new(&env)); - let normalized_currency = Self::normalize_currency(&env, ¤cy); - - // Use the owner index for O(owner_bills) traversal instead of O(NEXT_ID). - let owner_ids = Self::get_owner_bills(&env, &owner); + // Use the currency index for O(owner_currency_bills) traversal instead of O(owner_bills). + let currency_ids = Self::get_bills_by_owner_currency(&env, &owner, &normalized_currency); let mut staging: Vec<(u32, Bill)> = Vec::new(&env); - for id in owner_ids.iter() { + for id in currency_ids.iter() { if id <= cursor { continue; } let Some(bill) = bills.get(id) else { continue; }; - if bill.paid || bill.currency != normalized_currency { + if bill.paid { continue; } staging.push_back((id, bill)); From cd3978d3ebba843af38ddd09a9b1f64ff9a163a5 Mon Sep 17 00:00:00 2001 From: JACOB STANLEY Date: Wed, 17 Jun 2026 22:29:03 +0100 Subject: [PATCH 2/3] https://github.com/Menjay7/Remitwise-Contracts.git --- data_migration/src/lib.rs | 138 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/data_migration/src/lib.rs b/data_migration/src/lib.rs index 985e31e4..822ad627 100644 --- a/data_migration/src/lib.rs +++ b/data_migration/src/lib.rs @@ -2152,4 +2152,142 @@ mod tests { assert_eq!(imported_goals[0].owner, "=MALICIOUS"); assert_eq!(imported_goals[1].name, "+FORMULA"); } + + #[test] + fn test_import_from_json_rejects_incompatible_version_too_low() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.version = MIN_SUPPORTED_VERSION - 1; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_json(&bytes, &mut tracker, 123_456); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_json_rejects_incompatible_version_too_high() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.version = SCHEMA_VERSION + 1; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_json(&bytes, &mut tracker, 123_456); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_json_rejects_checksum_mismatch() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.checksum = "invalid_checksum".into(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_json(&bytes, &mut tracker, 123_456); + assert_eq!(result.unwrap_err(), MigrationError::ChecksumMismatch); + } + + #[test] + fn test_import_from_binary_rejects_incompatible_version_too_low() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.version = MIN_SUPPORTED_VERSION - 1; + let bytes = bincode::serialize(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_binary(&bytes, &mut tracker, 123_456); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_binary_rejects_incompatible_version_too_high() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.version = SCHEMA_VERSION + 1; + let bytes = bincode::serialize(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_binary(&bytes, &mut tracker, 123_456); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_binary_rejects_checksum_mismatch() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.checksum = "invalid_checksum".into(); + let bytes = bincode::serialize(&snapshot).unwrap(); + let mut tracker = MigrationTracker::new(); + let result = import_from_binary(&bytes, &mut tracker, 123_456); + assert_eq!(result.unwrap_err(), MigrationError::ChecksumMismatch); + } + + #[test] + fn test_import_from_json_untracked_rejects_incompatible_version_too_low() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.version = MIN_SUPPORTED_VERSION - 1; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = import_from_json_untracked(&bytes); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_json_untracked_rejects_incompatible_version_too_high() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.version = SCHEMA_VERSION + 1; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = import_from_json_untracked(&bytes); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_json_untracked_rejects_checksum_mismatch() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Json); + snapshot.header.checksum = "invalid_checksum".into(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = import_from_json_untracked(&bytes); + assert_eq!(result.unwrap_err(), MigrationError::ChecksumMismatch); + } + + #[test] + fn test_import_from_binary_untracked_rejects_incompatible_version_too_low() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.version = MIN_SUPPORTED_VERSION - 1; + let bytes = bincode::serialize(&snapshot).unwrap(); + let result = import_from_binary_untracked(&bytes); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 0, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_binary_untracked_rejects_incompatible_version_too_high() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.version = SCHEMA_VERSION + 1; + let bytes = bincode::serialize(&snapshot).unwrap(); + let result = import_from_binary_untracked(&bytes); + assert!(matches!( + result.unwrap_err(), + MigrationError::IncompatibleVersion { found: 2, min: 1, max: 1 } + )); + } + + #[test] + fn test_import_from_binary_untracked_rejects_checksum_mismatch() { + let mut snapshot = ExportSnapshot::new(sample_remittance_payload(), ExportFormat::Binary); + snapshot.header.checksum = "invalid_checksum".into(); + let bytes = bincode::serialize(&snapshot).unwrap(); + let result = import_from_binary_untracked(&bytes); + assert_eq!(result.unwrap_err(), MigrationError::ChecksumMismatch); + } } From c180a1081a22762bae181342ea3cd953bd707177 Mon Sep 17 00:00:00 2001 From: JACOB STANLEY Date: Wed, 17 Jun 2026 22:47:22 +0100 Subject: [PATCH 3/3] https://github.com/Menjay7/Remitwise-Contracts.git --- data_migration/src/lib.rs | 323 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/data_migration/src/lib.rs b/data_migration/src/lib.rs index 822ad627..b2fbb2c6 100644 --- a/data_migration/src/lib.rs +++ b/data_migration/src/lib.rs @@ -2290,4 +2290,327 @@ mod tests { let result = import_from_binary_untracked(&bytes); assert_eq!(result.unwrap_err(), MigrationError::ChecksumMismatch); } + + #[test] + fn test_csv_roundtrip_with_commas_in_names() { + let payload = SavingsGoalsExport { + next_id: 2, + goals: vec![ + SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Goal, with, commas".into(), + target_amount: 1_000, + current_amount: 500, + target_date: 2_000_000_000, + locked: false, + }, + SavingsGoalExport { + id: 2, + owner: "owner,2".into(), + name: "Normal Goal".into(), + target_amount: 2_000, + current_amount: 1_500, + target_date: 2_000_000_001, + locked: true, + }, + ], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 2); + assert_eq!(imported_goals[0].name, "Goal, with, commas"); + assert_eq!(imported_goals[1].owner, "owner,2"); + } + + #[test] + fn test_csv_roundtrip_with_quotes_in_names() { + let payload = SavingsGoalsExport { + next_id: 2, + goals: vec![ + SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Goal \"quoted\" text".into(), + target_amount: 1_000, + current_amount: 500, + target_date: 2_000_000_000, + locked: false, + }, + SavingsGoalExport { + id: 2, + owner: "owner\"2".into(), + name: "Normal Goal".into(), + target_amount: 2_000, + current_amount: 1_500, + target_date: 2_000_000_001, + locked: true, + }, + ], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 2); + assert_eq!(imported_goals[0].name, "Goal \"quoted\" text"); + assert_eq!(imported_goals[1].owner, "owner\"2"); + } + + #[test] + fn test_csv_roundtrip_with_newlines_in_names() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Goal\nwith\nnewlines".into(), + target_amount: 1_000, + current_amount: 500, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].name, "Goal\nwith\nnewlines"); + } + + #[test] + fn test_csv_roundtrip_with_zero_values() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Zero Goal".into(), + target_amount: 0, + current_amount: 0, + target_date: 0, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].target_amount, 0); + assert_eq!(imported_goals[0].current_amount, 0); + assert_eq!(imported_goals[0].target_date, 0); + } + + #[test] + fn test_csv_roundtrip_with_negative_amounts() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Negative Goal".into(), + target_amount: -1_000, + current_amount: -500, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].target_amount, -1_000); + assert_eq!(imported_goals[0].current_amount, -500); + } + + #[test] + fn test_csv_roundtrip_with_large_numbers() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Large Goal".into(), + target_amount: i64::MAX, + current_amount: i64::MAX - 1, + target_date: u64::MAX, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].target_amount, i64::MAX); + assert_eq!(imported_goals[0].current_amount, i64::MAX - 1); + assert_eq!(imported_goals[0].target_date, u64::MAX); + } + + #[test] + fn test_csv_roundtrip_with_tab_characters() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner\t1".into(), + name: "Goal\twith\ttabs".into(), + target_amount: 1_000, + current_amount: 500, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].owner, "owner\t1"); + assert_eq!(imported_goals[0].name, "Goal\twith\ttabs"); + } + + #[test] + fn test_csv_roundtrip_with_backslash_characters() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner\\1".into(), + name: "Goal\\with\\backslashes".into(), + target_amount: 1_000, + current_amount: 500, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 1); + assert_eq!(imported_goals[0].owner, "owner\\1"); + assert_eq!(imported_goals[0].name, "Goal\\with\\backslashes"); + } + + #[test] + fn test_csv_injection_prevention_tab_character_in_owner() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "\tSUM(A1:A10)".into(), + name: "Goal".into(), + target_amount: 1_000, + current_amount: 100, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let csv_string = String::from_utf8_lossy(&exported_bytes); + + // Tab is not a formula injection character, so it should not be escaped + assert!(csv_string.contains("\tSUM(A1:A10)"), "Tab should not be escaped"); + } + + #[test] + fn test_csv_injection_prevention_backslash_in_name() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "owner".into(), + name: "\\SUM(A1:A10)".into(), + target_amount: 1_000, + current_amount: 100, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let csv_string = String::from_utf8_lossy(&exported_bytes); + + // Backslash is not a formula injection character, so it should not be escaped + assert!(csv_string.contains("\\SUM(A1:A10)"), "Backslash should not be escaped"); + } + + #[test] + fn test_csv_injection_prevention_pipe_character_in_owner() { + let payload = SavingsGoalsExport { + next_id: 1, + goals: vec![SavingsGoalExport { + id: 1, + owner: "|SUM(A1:A10)".into(), + name: "Goal".into(), + target_amount: 1_000, + current_amount: 100, + target_date: 2_000_000_000, + locked: false, + }], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let csv_string = String::from_utf8_lossy(&exported_bytes); + + // Pipe is not a formula injection character, so it should not be escaped + assert!(csv_string.contains("|SUM(A1:A10)"), "Pipe should not be escaped"); + } + + #[test] + fn test_csv_roundtrip_preserves_all_fields() { + let payload = SavingsGoalsExport { + next_id: 5, + goals: vec![ + SavingsGoalExport { + id: 1, + owner: "owner1".into(), + name: "Goal 1".into(), + target_amount: 10_000, + current_amount: 5_000, + target_date: 1_700_000_000, + locked: false, + }, + SavingsGoalExport { + id: 2, + owner: "owner2".into(), + name: "Goal 2".into(), + target_amount: 20_000, + current_amount: 15_000, + target_date: 1_800_000_000, + locked: true, + }, + SavingsGoalExport { + id: 3, + owner: "owner3".into(), + name: "Goal 3".into(), + target_amount: 30_000, + current_amount: 0, + target_date: 1_900_000_000, + locked: false, + }, + ], + }; + + let exported_bytes = export_to_csv(&payload).unwrap(); + let imported_goals = import_goals_from_csv(&exported_bytes).unwrap(); + + assert_eq!(imported_goals.len(), 3); + for (i, goal) in imported_goals.iter().enumerate() { + assert_eq!(goal.id, payload.goals[i].id); + assert_eq!(goal.owner, payload.goals[i].owner); + assert_eq!(goal.name, payload.goals[i].name); + assert_eq!(goal.target_amount, payload.goals[i].target_amount); + assert_eq!(goal.current_amount, payload.goals[i].current_amount); + assert_eq!(goal.target_date, payload.goals[i].target_date); + assert_eq!(goal.locked, payload.goals[i].locked); + } + } }