diff --git a/apexchainx_calculator/src/api_stability.rs b/apexchainx_calculator/src/api_stability.rs index 43a0d69..eae3d82 100644 --- a/apexchainx_calculator/src/api_stability.rs +++ b/apexchainx_calculator/src/api_stability.rs @@ -89,7 +89,7 @@ pub fn canonical_field_counts() -> [(&'static str, u32); 31] { ("FailureCode", 3), ("FailureSchema", 2), ("HealthcheckResult", 3), - ("ConfigBundle", 2), + ("ConfigBundle", 3), ("AuditState", 10), ("ContractInfo", 11), ("HistoryPage", 3), diff --git a/apexchainx_calculator/src/calculation.rs b/apexchainx_calculator/src/calculation.rs index 22f45e2..1c85e97 100644 --- a/apexchainx_calculator/src/calculation.rs +++ b/apexchainx_calculator/src/calculation.rs @@ -158,6 +158,29 @@ pub fn calculate_sla_view( crate::SLACalculatorContract::check_version(env)?; let cfg = crate::SLACalculatorContract::load_config(env, &severity)?; let config_version_hash = crate::SLACalculatorContract::compute_config_version_hash(env)?; + + let history: Vec = env + .storage() + .instance() + .get(&HISTORY_KEY) + .unwrap_or_else(|| Vec::new(env)); + + let mut existing: Option = None; + for i in 0..history.len() { + let entry = history.get(i).unwrap(); + if entry.outage_id == outage_id { + existing = Some(entry); + } + } + if let Some(prev) = existing { + if prev.config_version_hash == config_version_hash { + if prev.mttr_minutes != mttr_minutes || prev.threshold_minutes != cfg.threshold_minutes { + return Err(SLAError::DuplicateOutageInput); + } + return Ok(prev); + } + } + compute_result( outage_id, mttr_minutes, diff --git a/apexchainx_calculator/src/config.rs b/apexchainx_calculator/src/config.rs index 58503b0..5a663a3 100644 --- a/apexchainx_calculator/src/config.rs +++ b/apexchainx_calculator/src/config.rs @@ -8,7 +8,7 @@ use soroban_sdk::{symbol_short, Env, Map, Symbol, Vec}; use crate::{ config_freeze, config_metadata, SLAConfig, SLAConfigEntry, SLAConfigSnapshot, SLAError, CONFIG_KEY, - CUSTOM_CONFIG_KEY, EVENT_CONFIG_UPD, EVENT_VERSION, + CONFIG_SNAPSHOT_SCHEMA_VERSION, CUSTOM_CONFIG_KEY, EVENT_CONFIG_UPD, EVENT_VERSION, }; /// Sets the SLA configuration for a given severity level. @@ -75,7 +75,7 @@ pub fn get_config_snapshot(env: &Env) -> Result { } Ok(SLAConfigSnapshot { - version: symbol_short!("v1"), + version: CONFIG_SNAPSHOT_SCHEMA_VERSION, entries, }) } @@ -201,7 +201,7 @@ pub fn get_custom_config_snapshot(env: &Env) -> Result Result<(), SLAError> { if env.storage().instance().has(&ADMIN_KEY) { return Err(SLAError::AlreadyInitialized); @@ -1656,7 +1674,7 @@ impl SLACalculatorContract { } Ok(SLAConfigSnapshot { - version: symbol_short!("v1"), + version: CONFIG_SNAPSHOT_SCHEMA_VERSION, entries, }) } @@ -1700,7 +1718,7 @@ impl SLACalculatorContract { } Ok(SLAConfigSnapshot { - version: symbol_short!("v1"), + version: CONFIG_SNAPSHOT_SCHEMA_VERSION, entries, }) } @@ -1742,7 +1760,7 @@ impl SLACalculatorContract { entries.push_back(SLAConfigEntry { severity, config }); } Ok(SLAConfigSnapshot { - version: symbol_short!("v1"), + version: CONFIG_SNAPSHOT_SCHEMA_VERSION, entries, }) } @@ -1850,8 +1868,13 @@ impl SLACalculatorContract { /// contract is initialised and on the current storage version. pub fn get_config_bundle(env: Env) -> Result, SLAError> { let snapshot = Self::get_config_snapshot(env.clone())?; - let schema = Self::get_result_schema(env)?; - Ok(Some(ConfigBundle { snapshot, schema })) + let schema = Self::get_result_schema(env.clone())?; + let config_version_hash = Self::compute_config_version_hash(&env)?; + Ok(Some(ConfigBundle { + snapshot, + schema, + config_version_hash, + })) } /// Returns the full audit state including roles, config, stats, and history. @@ -2224,11 +2247,29 @@ impl SLACalculatorContract { let cfg = Self::load_config(&env, &severity)?; let config_version_hash = Self::compute_config_version_hash(&env)?; - // Delegate to pure internal math without mutating state or emitting events. + // Apply duplicate/replay policy read-only against recorded history + let history: Vec = env + .storage() + .instance() + .get(&HISTORY_KEY) + .unwrap_or_else(|| Vec::new(&env)); + + let mut existing: Option = None; + for i in 0..history.len() { + let entry = history.get(i).unwrap(); + if entry.outage_id == outage_id { + existing = Some(entry); + } + } + if let Some(prev) = existing { + if prev.config_version_hash == config_version_hash { + if prev.mttr_minutes != mttr_minutes || prev.threshold_minutes != cfg.threshold_minutes { + return Err(SLAError::DuplicateOutageInput); + } + return Ok(prev); + } + } - // Use the current ledger timestamp so the view result matches the mutating - // path for the same inputs executed in the same ledger, while still avoiding - // any state writes or event emission. Self::compute_result( outage_id, mttr_minutes, diff --git a/apexchainx_calculator/src/schema_migration_tests.rs b/apexchainx_calculator/src/schema_migration_tests.rs index 5461cb5..2a25bd7 100644 --- a/apexchainx_calculator/src/schema_migration_tests.rs +++ b/apexchainx_calculator/src/schema_migration_tests.rs @@ -31,7 +31,8 @@ #[cfg(test)] mod tests { use crate::{ - SLACalculatorContract, SLACalculatorContractClient, RESULT_SCHEMA_FIELD_COUNT, RESULT_SCHEMA_VERSION, + SLACalculatorContract, SLACalculatorContractClient, CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT, + CONFIG_SNAPSHOT_SCHEMA_VERSION, RESULT_SCHEMA_FIELD_COUNT, RESULT_SCHEMA_VERSION, }; use soroban_sdk::{testutils::Address as _, Env, Symbol}; @@ -216,4 +217,32 @@ mod tests { panic!("get_config_bundle returned None after initialization"); } } + + // ----------------------------------------------------------------------- + // SLAConfigSnapshot sentinel: field count must match CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT + // ----------------------------------------------------------------------- + + #[test] + fn test_config_snapshot_schema_field_count_sentinel() { + use crate::SLAConfigSnapshot; + use soroban_sdk::{symbol_short, Env, Vec}; + + let env = Env::default(); + let sample = SLAConfigSnapshot { + version: CONFIG_SNAPSHOT_SCHEMA_VERSION, + entries: Vec::new(&env), + }; + + let SLAConfigSnapshot { + version: _, + entries: _, + } = sample; + + assert_eq!( + CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT, 2, + "CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT is out of sync with SLAConfigSnapshot. \ + Update lib.rs::CONFIG_SNAPSHOT_SCHEMA_FIELD_COUNT and \ + CONFIG_SNAPSHOT_SCHEMA_VERSION when adding or removing fields." + ); + } } diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 32b76b9..50bf1fd 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -64,6 +64,32 @@ fn test_initialize_stores_roles() { assert_eq!(client.get_operator(), actors.operator); } +#[test] +fn test_initialize_single_address_merged_roles() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register_contract(None, SLACalculatorContract); + let client = SLACalculatorContractClient::new(&env, &cid); + let single_key = soroban_sdk::Address::generate(&env); + + client.initialize(&single_key, &single_key); + + assert_eq!(client.get_admin(), single_key); + assert_eq!(client.get_operator(), single_key); + + // Single key can invoke both admin and operator methods + let result = client.calculate_sla( + &single_key, + &symbol_short!("INC001"), + &symbol_short!("critical"), + &10, + ); + assert_eq!(result.status, symbol_short!("met")); + + client.set_config(&single_key, &symbol_short!("critical"), &20, &200, &1000); + assert_eq!(client.get_config(&symbol_short!("critical")).threshold_minutes, 20); +} + #[test] #[should_panic] fn test_double_initialize_fails() { @@ -5210,6 +5236,46 @@ fn test_invariance_critical_all_rating_zones() { } } +#[test] +fn test_calculate_sla_view_detects_duplicate_conflict() { + let (_env, client, actors) = setup(); + let outage_id = symbol_short!("OUTDUP1"); + let severity = symbol_short!("critical"); + + // First, record a calculation via mutating path + let res1 = client.calculate_sla(&actors.operator, &outage_id, &severity, &10); + assert_eq!(res1.status, symbol_short!("met")); + + // View call with conflicting mttr must return DuplicateOutageInput error + let res2 = client.try_calculate_sla_view(&outage_id, &severity, &20); + assert_eq!(res2, Err(Ok(SLAError::DuplicateOutageInput))); +} + +#[test] +fn test_calculate_sla_view_handles_replay() { + let (env, client, actors) = setup(); + env.ledger().set_timestamp(1000); + let outage_id = symbol_short!("OUTREP1"); + let severity = symbol_short!("critical"); + + // Record calculation at t = 1000 + let orig = client.calculate_sla(&actors.operator, &outage_id, &severity, &10); + assert_eq!(orig.recorded_at, 1000); + + // Advance time to t = 2000 + env.ledger().set_timestamp(2000); + + // View call for identical outage_id and mttr must replay stored result with original timestamp (1000) + let replayed = client.calculate_sla_view(&outage_id, &severity, &10); + assert_eq!(replayed.recorded_at, 1000); + assert_eq!(replayed.amount, orig.amount); + assert_eq!(replayed.status, orig.status); + + // Assert side-effect free: history count remains 1 + let history = client.get_history_page(&0, &10); + assert_eq!(history.entries.len(), 1); +} + #[test] fn test_invariance_high_all_rating_zones() { let (_env, client, actors) = setup(); @@ -8086,6 +8152,7 @@ fn test_240_all_contracttype_structures_round_trip_serialization() { deprecated_symbols, severity_aliases, }, + config_version_hash: 12345, }; let scval_bundle: soroban_sdk::Val = config_bundle.clone().try_into_val(&env).unwrap(); let restored_bundle: ConfigBundle = scval_bundle.try_into_val(&env).unwrap();