Complete phase 1 of encrypted transaction caching with AES-GCM encryption, PBKDF2 key derivation, and secure caching infrastructure for improved performance and security.
This commit is contained in:
225
banks2ff/src/adapters/gocardless/transaction_cache.rs
Normal file
225
banks2ff/src/adapters/gocardless/transaction_cache.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use crate::adapters::gocardless::encryption::Encryption;
|
||||
use gocardless_client::models::Transaction;
|
||||
use rand;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AccountTransactionCache {
|
||||
pub account_id: String,
|
||||
pub ranges: Vec<CachedRange>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CachedRange {
|
||||
pub start_date: NaiveDate,
|
||||
pub end_date: NaiveDate,
|
||||
pub transactions: Vec<Transaction>,
|
||||
}
|
||||
|
||||
impl AccountTransactionCache {
|
||||
/// Get cache file path for an account
|
||||
fn get_cache_path(account_id: &str) -> String {
|
||||
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
|
||||
format!("{}/transactions/{}.enc", cache_dir, account_id)
|
||||
}
|
||||
|
||||
/// Load cache from disk
|
||||
pub fn load(account_id: &str) -> Result<Self> {
|
||||
let path = Self::get_cache_path(account_id);
|
||||
|
||||
if !Path::new(&path).exists() {
|
||||
// Return empty cache if file doesn't exist
|
||||
return Ok(Self {
|
||||
account_id: account_id.to_string(),
|
||||
ranges: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Read encrypted data
|
||||
let encrypted_data = std::fs::read(&path)?;
|
||||
let json_data = Encryption::decrypt(&encrypted_data)?;
|
||||
|
||||
// Deserialize
|
||||
let cache: Self = serde_json::from_slice(&json_data)?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
/// Save cache to disk
|
||||
pub fn save(&self) -> Result<()> {
|
||||
// Serialize to JSON
|
||||
let json_data = serde_json::to_vec(self)?;
|
||||
|
||||
// Encrypt
|
||||
let encrypted_data = Encryption::encrypt(&json_data)?;
|
||||
|
||||
// Write to file (create directory if needed)
|
||||
let path = Self::get_cache_path(&self.account_id);
|
||||
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, encrypted_data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
fn setup_test_env(test_name: &str) -> String {
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Use a unique cache directory for each test to avoid interference
|
||||
// Include random component and timestamp for true parallelism safety
|
||||
let random_suffix = rand::random::<u64>();
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let cache_dir = format!("tmp/test-cache-{}-{}-{}", test_name, random_suffix, timestamp);
|
||||
env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone());
|
||||
cache_dir
|
||||
}
|
||||
|
||||
fn cleanup_test_dir(cache_dir: &str) {
|
||||
// Wait a bit longer to ensure all file operations are complete
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Try multiple times in case of temporary file locks
|
||||
for _ in 0..5 {
|
||||
if std::path::Path::new(cache_dir).exists() {
|
||||
if std::fs::remove_dir_all(cache_dir).is_ok() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break; // Directory already gone
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_cache() {
|
||||
let cache_dir = setup_test_env("nonexistent");
|
||||
let cache = AccountTransactionCache::load("nonexistent").unwrap();
|
||||
assert_eq!(cache.account_id, "nonexistent");
|
||||
assert!(cache.ranges.is_empty());
|
||||
cleanup_test_dir(&cache_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_empty_cache() {
|
||||
let cache_dir = setup_test_env("empty");
|
||||
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test_account_empty".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Save
|
||||
cache.save().expect("Save should succeed");
|
||||
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let loaded = AccountTransactionCache::load("test_account_empty").expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_empty");
|
||||
assert!(loaded.ranges.is_empty());
|
||||
|
||||
cleanup_test_dir(&cache_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_with_data() {
|
||||
let cache_dir = setup_test_env("data");
|
||||
|
||||
let transaction = Transaction {
|
||||
transaction_id: Some("test-tx-1".to_string()),
|
||||
booking_date: Some("2024-01-01".to_string()),
|
||||
value_date: None,
|
||||
transaction_amount: gocardless_client::models::TransactionAmount {
|
||||
amount: "100.00".to_string(),
|
||||
currency: "EUR".to_string(),
|
||||
},
|
||||
currency_exchange: None,
|
||||
creditor_name: Some("Test Creditor".to_string()),
|
||||
creditor_account: None,
|
||||
debtor_name: None,
|
||||
debtor_account: None,
|
||||
remittance_information_unstructured: Some("Test payment".to_string()),
|
||||
proprietary_bank_transaction_code: None,
|
||||
};
|
||||
|
||||
let range = CachedRange {
|
||||
start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
|
||||
end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
|
||||
transactions: vec![transaction],
|
||||
};
|
||||
|
||||
let cache = AccountTransactionCache {
|
||||
account_id: "test_account_data".to_string(),
|
||||
ranges: vec![range],
|
||||
};
|
||||
|
||||
// Ensure env vars are set before save
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Save
|
||||
cache.save().expect("Save should succeed");
|
||||
|
||||
// Ensure env vars are set before load
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
// Load
|
||||
let loaded = AccountTransactionCache::load("test_account_data").expect("Load should succeed");
|
||||
|
||||
assert_eq!(loaded.account_id, "test_account_data");
|
||||
assert_eq!(loaded.ranges.len(), 1);
|
||||
assert_eq!(loaded.ranges[0].transactions.len(), 1);
|
||||
assert_eq!(loaded.ranges[0].transactions[0].transaction_id, Some("test-tx-1".to_string()));
|
||||
|
||||
cleanup_test_dir(&cache_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_load_different_accounts() {
|
||||
let cache_dir = setup_test_env("different_accounts");
|
||||
|
||||
// Save cache for account A
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let cache_a = AccountTransactionCache {
|
||||
account_id: "account_a".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
cache_a.save().unwrap();
|
||||
|
||||
// Save cache for account B
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let cache_b = AccountTransactionCache {
|
||||
account_id: "account_b".to_string(),
|
||||
ranges: Vec::new(),
|
||||
};
|
||||
cache_b.save().unwrap();
|
||||
|
||||
// Load account A
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let loaded_a = AccountTransactionCache::load("account_a").unwrap();
|
||||
assert_eq!(loaded_a.account_id, "account_a");
|
||||
|
||||
// Load account B
|
||||
env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key");
|
||||
let loaded_b = AccountTransactionCache::load("account_b").unwrap();
|
||||
assert_eq!(loaded_b.account_id, "account_b");
|
||||
|
||||
cleanup_test_dir(&cache_dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user