Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ node_modules/
# Environment secrets
.env
.env.*
!.env.example
!.env.example


/target/
**/*.rs.bk
*.wasm
19 changes: 10 additions & 9 deletions apexchainx_calculator/src/contract_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,15 @@ pub struct ContractInfo {
/// identity, version posture, and feature availability before resuming
/// operations. The `schema_version` field lets consumers detect when
/// new fields have been added to `ContractInfo`.
///
/// This function intentionally bypasses `check_version()` so backend consumers
/// can observe `needs_migration == true` pre-migration during startup handshake.
pub fn get_contract_info(env: &Env) -> Result<ContractInfo, SLAError> {
SLACalculatorContract::check_version(env)?;

let stored_version: u32 = env
.storage()
.instance()
.get(&crate::STORAGE_VERSION_KEY)
.unwrap_or(0);
.ok_or(SLAError::NotInitialized)?;
let needs_migration = stored_version != STORAGE_VERSION;
let is_paused: bool = env.storage().instance().get(&crate::PAUSED_KEY).unwrap_or(false);

Expand All @@ -114,7 +115,7 @@ pub fn get_contract_info(env: &Env) -> Result<ContractInfo, SLAError> {
schema_version: CONTRACT_INFO_SCHEMA_VERSION,
contract_name: symbol_short!("sla_calc"),
contract_version: symbol_short!("0_1_0"),
storage_version: STORAGE_VERSION,
storage_version: stored_version,
result_schema_version: RESULT_SCHEMA_VERSION,
event_version: crate::event_schema::current_event_version(),
needs_migration,
Expand Down Expand Up @@ -253,13 +254,13 @@ mod tests {
let info = get_contract_info(&env).unwrap();
assert!(!info.needs_migration);

// Corrupt the stored version: get_contract_info must now surface
// an explicit VersionMismatch error (the contract reports that it
// needs migration instead of returning stale metadata).
// Change stored version: get_contract_info returns a ContractInfo
// struct with needs_migration: true and the stored storage_version.
env.storage().instance().set(&crate::STORAGE_VERSION_KEY, &99u32);

let err = get_contract_info(&env).unwrap_err();
assert_eq!(err, crate::SLAError::VersionMismatch);
let info_mig = get_contract_info(&env).unwrap();
assert!(info_mig.needs_migration);
assert_eq!(info_mig.storage_version, 99u32);
});
}

Expand Down
43 changes: 43 additions & 0 deletions apexchainx_calculator/src/schema_migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,47 @@ mod tests {
panic!("get_config_bundle returned None after initialization");
}
}

// -----------------------------------------------------------------------
// Multi-arm migration harness and chaining test pattern (#505)
// -----------------------------------------------------------------------

/// **#505 – Multi-arm migration chaining & idempotency test pattern.**
///
/// Demonstrates and validates the multi-step migration pattern (`v0 -> v1 -> ...`).
/// Verifies that a contract starting at a historical storage version (`v0`)
/// advances through all sequential migration arms to reach `STORAGE_VERSION`
/// in a single `migrate()` call, and that subsequent calls are idempotent no-ops.
#[test]
fn test_multi_arm_migration_chaining_and_idempotency() {
use crate::STORAGE_VERSION;
let (env, client) = setup();

let admin = soroban_sdk::Address::generate(&env);

// 1. Synthesize a v0 contract state by resetting STORAGE_VERSION_KEY to 0
env.storage().instance().set(&crate::STORAGE_VERSION_KEY, &0u32);

// Verify pre-migration state reports needs_migration = true
let mig_state = client.get_migration_state();
assert_eq!(mig_state.stored_version, 0);
assert_eq!(mig_state.expected_version, STORAGE_VERSION);
assert!(mig_state.needs_migration);

// 2. Invoke migrate() — advances v0 -> v1 (and any subsequent arms sequentially)
let result = client.try_migrate(&admin);
assert!(result.is_ok(), "migrate() failed on v0 contract state");

// 3. Verify post-migration state is fully updated to current STORAGE_VERSION
let post_state = client.get_migration_state();
assert_eq!(post_state.stored_version, STORAGE_VERSION);
assert!(!post_state.needs_migration);

// 4. Idempotency test: calling migrate() again when already current is a safe no-op
let retry_result = client.try_migrate(&admin);
assert!(retry_result.is_ok(), "second migrate() call must be a safe idempotent no-op");
let retry_state = client.get_migration_state();
assert_eq!(retry_state.stored_version, STORAGE_VERSION);
assert!(!retry_state.needs_migration);
}
}
140 changes: 140 additions & 0 deletions apexchainx_calculator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,79 @@ fn test_get_history_page_with_meta_items_match_get_history_page() {
}
}

/// #503 - Matrix test asserting get_history_page_with_meta(offset, limit).items == get_history_page(offset, limit)
/// and has_more against a manual count across edge cases (empty history, offset beyond end, limit 0, limit exceeding remaining).
#[test]
fn test_get_history_page_equivalence_and_has_more_matrix() {
let (_env, client, actors) = setup();

// 1. Test empty history matrix
let empty_offsets = [0u32, 1, 10, u32::MAX];
let limits = [0u32, 1, 2, 5, 100, u32::MAX];

for &offset in &empty_offsets {
for &limit in &limits {
let plain = client.get_history_page(&offset, &limit);
let meta = client.get_history_page_with_meta(&offset, &limit);

assert_eq!(
meta.items, plain,
"empty history items mismatch at offset={} limit={}",
offset, limit
);
assert_eq!(meta.total, 0);
assert!(
!meta.has_more,
"has_more should be false for empty history at offset={} limit={}",
offset, limit
);
}
}

// 2. Test populated history matrix (len = 5)
for i in 0..5u32 {
let oid = Symbol::new(&_env, &alloc::format!("PG_EQ_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}
let total_len = 5u32;

let offsets = [0u32, 1, 2, 4, 5, 6, 100, u32::MAX];

for &offset in &offsets {
for &limit in &limits {
let plain = client.get_history_page(&offset, &limit);
let meta = client.get_history_page_with_meta(&offset, &limit);

// Equivalence assertion: meta.items == get_history_page(offset, limit)
assert_eq!(
meta.items, plain,
"items mismatch at offset={} limit={}",
offset, limit
);
assert_eq!(
meta.total, total_len,
"total mismatch at offset={} limit={}",
offset, limit
);

// Manual calculation for has_more
let expected_has_more = if offset >= total_len {
false
} else if limit == 0 {
true
} else {
offset.saturating_add(limit.min(200)) < total_len
};

assert_eq!(
meta.has_more, expected_has_more,
"has_more mismatch at offset={} limit={}: expected {}, got {}",
offset, limit, expected_has_more, meta.has_more
);
}
}
}

#[test]
fn test_get_history_page_with_meta_saturating_arithmetic() {
let (_env, client, actors) = setup();
Expand Down Expand Up @@ -7654,6 +7727,73 @@ fn test_economic_exposure_independent_of_history() {
assert_eq!(exposure_before, exposure_after);
}

// ============================================================
// #504 – Custom severity view behavior pinning tests
// ============================================================

/// #504 - Pins current canonical-only behavior of get_economic_exposure when a custom severity is registered.
/// Currently get_economic_exposure iterates canonical_severities only; registered custom severities are omitted from breakdown.
#[test]
fn test_economic_exposure_with_custom_severity_pins_canonical_only_behavior() {
let (_env, client, actors) = setup();

// Register a custom severity ("warning") with valid bounds (threshold 90, penalty 5, reward 200)
client.set_custom_severity(&actors.admin, &symbol_short!("warning"), &90, &5, &200);

let exposure = client.get_economic_exposure();

// Current behavior: breakdown contains only the 4 canonical severities; custom severity is omitted
assert_eq!(exposure.breakdown.len(), 4);
let has_warning = exposure
.breakdown
.iter()
.any(|e| e.severity == symbol_short!("warning"));
assert!(
!has_warning,
"get_economic_exposure currently omits custom severities"
);

// Totals match only the canonical sum (5700 max reward, 185 penalty rate)
assert_eq!(exposure.total_max_reward, 5700);
assert_eq!(exposure.total_penalty_per_minute, 185);
}

/// #504 - Pins current behavior of get_severity_telemetry when calculate_sla runs against a custom severity.
/// Currently custom-severity calculate_sla calls map to index 0 (critical lane), misattributing counts to critical.
#[test]
fn test_severity_telemetry_with_custom_severity_pins_canonical_lane_attribution() {
let (env, client, actors) = setup();
env.ledger().set_timestamp(1000);

// Register a custom severity ("warning")
client.set_custom_severity(&actors.admin, &symbol_short!("warning"), &90, &5, &200);

// Execute calculate_sla for custom severity causing a violation (mttr 100 > threshold 90)
client.calculate_sla(
&actors.operator,
&symbol_short!("CUST001"),
&symbol_short!("warning"),
&100,
);

let telemetry = client.get_severity_telemetry();

// The telemetry output has 5 entries (4 canonical + 1 custom)
assert_eq!(telemetry.len(), 5);

// Current behavior: custom activity is misattributed to critical lane (index 0)
let critical = telemetry.get(0).unwrap();
assert_eq!(critical.severity, symbol_short!("critical"));
assert_eq!(critical.calculations, 1);
assert_eq!(critical.violations, 1);

// The custom severity entry ("warning") shows 0 calculations and 0 violations
let warning = telemetry.get(4).unwrap();
assert_eq!(warning.severity, symbol_short!("warning"));
assert_eq!(warning.calculations, 0);
assert_eq!(warning.violations, 0);
}

// ============================================================
// #218 – Read-only healthcheck path
// ============================================================
Expand Down
4 changes: 1 addition & 3 deletions docs/STORAGE_KEY_MIGRATION_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ the questions below before marking your PR ready for review.
- [ ] **`init_missing_storage_defaults` updated.** If the key must be
present after a fresh `initialize()` call, it is written there too.

- [ ] **Tests cover the migration path.** At minimum: a test that calls
`migrate()` on a contract state that lacks the new key, then reads the
key and asserts it has the expected default value.
- [ ] **Tests cover the migration path.** At minimum: a test following the multi-arm chaining test pattern in `schema_migration_tests.rs` (`test_multi_arm_migration_chaining_and_idempotency`) that calls `migrate()` on a synthesized past contract state, asserts sequential version transition and defaults, and verifies idempotency.

---

Expand Down
Loading