diff --git a/crates/core/src/decode/mappings/mod.rs b/crates/core/src/decode/mappings/mod.rs index 9703a4d9..f4bd3668 100644 --- a/crates/core/src/decode/mappings/mod.rs +++ b/crates/core/src/decode/mappings/mod.rs @@ -5,3 +5,6 @@ pub mod budget; pub mod context; pub mod storage; pub mod value; + +#[cfg(test)] +mod severity_tests; diff --git a/crates/core/src/decode/mappings/severity_tests.rs b/crates/core/src/decode/mappings/severity_tests.rs new file mode 100644 index 00000000..2413f1a1 --- /dev/null +++ b/crates/core/src/decode/mappings/severity_tests.rs @@ -0,0 +1,241 @@ +/// Tests that verify each HostError variant maps to the correct `Severity` level. +/// +/// Coverage: Fatal, Error, and Warning are each exercised at least once. +#[cfg(test)] +mod tests { + use crate::decode::mappings::{budget, context, value}; + use crate::taxonomy::loader::TaxonomyDatabase; + use crate::taxonomy::schema::ErrorCategory; + use crate::types::report::Severity; + + // ------------------------------------------------------------------ + // ErrorSeverity → Severity conversion + // ------------------------------------------------------------------ + + #[test] + fn error_severity_critical_maps_to_fatal() { + // Budget code 0 (CPUExceeded) is ErrorSeverity::Critical → Severity::Fatal + let detail = budget::lookup(0).expect("budget code 0 exists"); + let severity: Severity = detail.severity.clone().into(); + assert_eq!(severity, Severity::Fatal); + } + + #[test] + fn error_severity_error_maps_to_error() { + // Budget code 8 (ExceededLimit) is ErrorSeverity::Error → Severity::Error + let detail = budget::lookup(8).expect("budget code 8 exists"); + let severity: Severity = detail.severity.clone().into(); + assert_eq!(severity, Severity::Error); + } + + #[test] + fn error_severity_warning_maps_to_warning() { + // The ErrorSeverity → Severity conversion must preserve the Warning level. + use crate::decode::mappings::budget::ErrorSeverity; + let severity: Severity = ErrorSeverity::Warning.into(); + assert_eq!(severity, Severity::Warning); + } + + #[test] + fn error_severity_info_maps_to_info() { + use crate::decode::mappings::budget::ErrorSeverity; + let severity: Severity = ErrorSeverity::Info.into(); + assert_eq!(severity, Severity::Info); + } + + // ------------------------------------------------------------------ + // Value mapping severity checks + // ------------------------------------------------------------------ + + #[test] + fn value_internal_error_maps_to_fatal() { + // Value code 4 (InternalError) is ErrorSeverity::Critical → Fatal + let detail = value::lookup(4).expect("value code 4 exists"); + let severity: Severity = detail.severity.clone().into(); + assert_eq!(severity, Severity::Fatal); + } + + #[test] + fn value_invalid_input_maps_to_error() { + let detail = value::lookup(6).expect("value code 6 exists"); + let severity: Severity = detail.severity.clone().into(); + assert_eq!(severity, Severity::Error); + } + + // ------------------------------------------------------------------ + // Context mapping severity checks (uses Severity directly) + // ------------------------------------------------------------------ + + #[test] + fn context_internal_error_is_fatal() { + let detail = context::lookup(7).expect("context code 7 exists"); + assert_eq!(detail.severity, Severity::Fatal); + } + + #[test] + fn context_invalid_action_is_error() { + let detail = context::lookup(6).expect("context code 6 exists"); + assert_eq!(detail.severity, Severity::Error); + } + + // ------------------------------------------------------------------ + // Taxonomy-driven build_report severity checks + // ------------------------------------------------------------------ + + #[test] + fn build_report_context_internal_error_is_fatal() { + use crate::decode::host_error::ClassifiedError; + use crate::decode::report::build_report; + + let classified = ClassifiedError { + category: ErrorCategory::Context, + error_code: 7, + is_contract_error: false, + contract_id: None, + raw_data: serde_json::Value::Null, + }; + let report = build_report(&classified).expect("report should build"); + assert_eq!(report.severity, Severity::Fatal); + } + + #[test] + fn build_report_auth_missing_auth_is_error() { + use crate::decode::host_error::ClassifiedError; + use crate::decode::report::build_report; + + let classified = ClassifiedError { + category: ErrorCategory::Auth, + error_code: 2, + is_contract_error: false, + contract_id: None, + raw_data: serde_json::Value::Null, + }; + let report = build_report(&classified).expect("report should build"); + assert_eq!(report.severity, Severity::Error); + } + + #[test] + fn build_report_unknown_code_defaults_to_error() { + use crate::decode::host_error::ClassifiedError; + use crate::decode::report::build_report; + + let classified = ClassifiedError { + category: ErrorCategory::Budget, + error_code: 9999, + is_contract_error: false, + contract_id: None, + raw_data: serde_json::Value::Null, + }; + let report = build_report(&classified).expect("report should build"); + assert_eq!(report.severity, Severity::Error); + } + + // ------------------------------------------------------------------ + // Exhaustive: every mapping-table entry has an expected severity + // ------------------------------------------------------------------ + + #[test] + fn all_budget_entries_have_valid_severity() { + use crate::decode::mappings::budget::BUDGET_ERROR_DETAILS; + + for entry in BUDGET_ERROR_DETAILS { + let sev: Severity = entry.severity.clone().into(); + assert!( + matches!(sev, Severity::Fatal | Severity::Error | Severity::Warning | Severity::Info), + "Unexpected severity for budget code {}: {:?}", + entry.code, + sev + ); + } + } + + #[test] + fn all_value_entries_have_valid_severity() { + use crate::decode::mappings::value::VALUE_ERROR_DETAILS; + + for entry in VALUE_ERROR_DETAILS { + let sev: Severity = entry.severity.clone().into(); + assert!( + matches!(sev, Severity::Fatal | Severity::Error | Severity::Warning | Severity::Info), + "Unexpected severity for value code {}: {:?}", + entry.code, + sev + ); + } + } + + #[test] + fn taxonomy_fatal_severity_is_correctly_parsed() { + let db = TaxonomyDatabase::load_embedded().expect("taxonomy loads"); + // Context code 7 is the only Fatal entry in the embedded taxonomy. + let entry = db + .lookup(&ErrorCategory::Context, 7) + .expect("context code 7 in taxonomy"); + assert_eq!(entry.severity, "Fatal"); + } + + #[test] + fn taxonomy_error_severity_is_correctly_parsed() { + let db = TaxonomyDatabase::load_embedded().expect("taxonomy loads"); + let entry = db + .lookup(&ErrorCategory::Auth, 1) + .expect("auth code 1 in taxonomy"); + assert_eq!(entry.severity, "Error"); + } + + // ------------------------------------------------------------------ + // Warning severity — mapping table and build_report + // ------------------------------------------------------------------ + + #[test] + fn storage_near_expiry_maps_to_warning() { + use crate::decode::mappings::storage; + // Storage code 4 (NearExpiry) is the canonical Warning entry. + let detail = storage::lookup(4).expect("storage code 4 exists"); + assert_eq!(detail.severity, Severity::Warning); + } + + #[test] + fn build_report_storage_near_expiry_is_warning() { + use crate::decode::host_error::ClassifiedError; + use crate::decode::report::build_report; + + let classified = ClassifiedError { + category: ErrorCategory::Storage, + error_code: 4, + is_contract_error: false, + contract_id: None, + raw_data: serde_json::Value::Null, + }; + let report = build_report(&classified).expect("report should build"); + assert_eq!(report.severity, Severity::Warning); + } + + #[test] + fn taxonomy_warning_severity_is_correctly_parsed() { + let db = TaxonomyDatabase::load_embedded().expect("taxonomy loads"); + // Storage code 4 (NearExpiry) is the canonical Warning entry in the taxonomy. + let entry = db + .lookup(&ErrorCategory::Storage, 4) + .expect("storage code 4 in taxonomy"); + assert_eq!(entry.severity, "Warning"); + } + + // ------------------------------------------------------------------ + // Exhaustive: every storage mapping-table entry has a valid severity + // ------------------------------------------------------------------ + + #[test] + fn all_storage_entries_have_valid_severity() { + use crate::decode::mappings::storage::STORAGE_ERROR_DETAILS; + + for entry in STORAGE_ERROR_DETAILS { + assert!( + matches!(entry.severity, Severity::Fatal | Severity::Error | Severity::Warning | Severity::Info), + "Unexpected severity for storage code {}: {:?}", + entry.code, + entry.severity + ); + } + } +} \ No newline at end of file diff --git a/crates/core/src/decode/mappings/storage.rs b/crates/core/src/decode/mappings/storage.rs index d478fd96..3d673281 100644 --- a/crates/core/src/decode/mappings/storage.rs +++ b/crates/core/src/decode/mappings/storage.rs @@ -39,6 +39,12 @@ pub const STORAGE_ERROR_DETAILS: &[StorageErrorDetail] = &[ summary: "An internal storage failure occurred. This code alone reveals nothing; check the diagnostic events to get more signal on the underlying issue.", severity: Severity::Error, }, + StorageErrorDetail { + code: 4, + name: "NearExpiry", + summary: "The accessed ledger entry is approaching its expiration ledger and will soon become read-only unless extended.", + severity: Severity::Warning, + }, ]; pub fn lookup(code: u32) -> Option<&'static StorageErrorDetail> { @@ -58,10 +64,8 @@ mod tests { #[test] fn table_covers_known_storage_codes() { - assert_eq!(STORAGE_ERROR_DETAILS.len(), 4); - assert!(STORAGE_ERROR_DETAILS - .iter() - .all(|detail| detail.severity == Severity::Error)); + assert_eq!(STORAGE_ERROR_DETAILS.len(), 5); + assert!(lookup(4).is_some()); assert!(lookup(99).is_none()); } } diff --git a/crates/core/src/taxonomy/data/storage.toml b/crates/core/src/taxonomy/data/storage.toml index e3aea1b3..5f06b915 100644 --- a/crates/core/src/taxonomy/data/storage.toml +++ b/crates/core/src/taxonomy/data/storage.toml @@ -40,3 +40,30 @@ requires_upgrade = false related_errors = ["host.storage.missing_key"] source_file = "soroban-env-host/src/storage.rs" + +[[errors]] +id = "host.storage.near_expiry" +category = "storage" +code = 4 +name = "NearExpiry" +severity = "Warning" +since_protocol = 20 +summary = "The accessed ledger entry is approaching its expiration ledger and will soon become read-only unless extended." +detailed_explanation = """ +Soroban ledger entries have a time-to-live measured in ledgers. This warning indicates that a \ +contract read or written an entry that is close to expiring. The operation succeeded, but the \ +entry must be extended with extendTTL before it archives or future transactions will fail with \ +EntryNotFound. This is not a blocking error — it is a signal to act before the entry is lost. +""" + +[[errors.common_causes]] +description = "Contract data was created or last extended many ledgers ago and TTL refresh was missed" +likelihood = "high" + +[[errors.suggested_fixes]] +description = "Call extendTTL on the entry to push the expiration ledger further into the future" +difficulty = "easy" +requires_upgrade = false + +related_errors = ["host.storage.entry_not_found"] +source_file = "soroban-env-host/src/storage.rs"