From e376232a96dc2e0c61b8ef39342d7baf8f2bdb7d Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Tue, 30 Jun 2026 03:37:30 +0100 Subject: [PATCH 1/2] Fix rate-limit error envelope and add contract event-coverage test suite - Align the listener's 429 rate-limit response body with Issue #253/#292's spec ({error:"TooManyRequests", message:"Rate limit exceeded. Please try again later."}); the underlying limiter/middleware/config/tests already existed. - Fix pre-existing merge corruption blocking compilation: interleaved event structs and an unclosed brace in the Soroban contract, a broken reputation_logic storage key/event-publish path, duplicate method in event-registry.ts, a type error in batch-validation-service.ts, and a missing brace in the listener's index.ts entrypoint. - Fix a notification_validation_test.rs helper that silently misread the priority topic as the category once priority became a trailing topic. - Add contract/contracts/hello-world/src/tests/event_emission_test.rs (Issue #291): full structural event-tuple assertions (address + all topics + data) across 22/23 event types, multi-event ordering checks, and negative tests proving failed transactions emit nothing observable. Co-Authored-By: Claude Sonnet 4.6 --- .../hello-world/src/autoshare_logic.rs | 15 +- .../contracts/hello-world/src/base/events.rs | 34 +- .../hello-world/src/base/reputation.rs | 4 +- contract/contracts/hello-world/src/lib.rs | 7 +- .../hello-world/src/reputation_logic.rs | 92 +-- .../src/tests/event_emission_test.rs | 742 ++++++++++++++++++ .../src/tests/notification_validation_test.rs | 10 +- listener/API.md | 4 +- listener/RATE-LIMITING-GUIDE.md | 4 +- listener/src/api/rate-limiter.test.ts | 4 +- listener/src/api/rate-limiter.ts | 4 +- listener/src/index.ts | 2 + .../src/services/batch-validation-service.ts | 2 +- listener/src/store/event-registry.ts | 4 - 14 files changed, 839 insertions(+), 89 deletions(-) create mode 100644 contract/contracts/hello-world/src/tests/event_emission_test.rs diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index 09f9fbe..d910c54 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,16 +1,11 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, - AutoshareUpdated, BatchNotificationsCreated, CategoryRegistered, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, + AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, + ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, + NotificationCategory, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, NotificationScheduled, - NotificationExtended, NotificationPriority, NotificationRevoked, NotificationScheduled, - ScheduledNotificationCancelled, Withdrawal, - NotificationPriority, NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, - Withdrawal, BatchProcessingCompleted, - NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, - NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, - SchemaVersionSet, NotificationAccessed, + ScheduledNotificationCancelled, SchemaVersionSet, Withdrawal, }; use crate::base::types::{ AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, @@ -59,6 +54,8 @@ pub enum DataKey { RegisteredCategories, /// Stores the current on-chain notification schema version. SchemaVersion, + /// Per-sender reputation record. + Reputation(Address), } // ============================================================================ diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index 2df8e08..b7579af 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -319,6 +319,13 @@ pub struct NotificationRevoked { pub struct BatchProcessingCompleted { #[topic] pub batch_id: BytesN<32>, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub processed_count: u32, +} + /// Emitted when a scheduled notification's expiry period is extended by an authorized sender. #[contractevent(data_format = "single-value")] #[derive(Clone)] @@ -336,11 +343,20 @@ pub struct NotificationExtended { /// Emitted when a sender's reputation score is updated. /// Triggered by successful or failed notification delivery. -#[contractevent(data_format = "single-value")] +#[contractevent] #[derive(Clone)] pub struct ReputationUpdated { #[topic] pub sender: Address, + #[topic] + pub category: NotificationCategory, + #[topic] + pub priority: NotificationPriority, + pub new_score: i64, + pub successful_count: u32, + pub failed_count: u32, +} + /// Emitted when protocol-level notification limits are configured or updated. #[contractevent] #[derive(Clone)] @@ -351,13 +367,14 @@ pub struct NotificationLimitsConfigured { pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, - pub new_score: i64, - pub successful_count: u32, - pub failed_count: u32, + pub max_payload_size: u32, + pub max_expiration_seconds: u64, + pub min_expiration_seconds: u64, + pub max_batch_size: u32, } /// Emitted when a sender's reputation tier changes (e.g., from Bronze to Silver). -#[contractevent(data_format = "single-value")] +#[contractevent] #[derive(Clone)] pub struct ReputationTierChanged { #[topic] @@ -371,13 +388,6 @@ pub struct ReputationTierChanged { pub reputation_score: i64, } - pub processed_count: u32, - pub max_payload_size: u32, - pub max_expiration_seconds: u64, - pub min_expiration_seconds: u64, - pub max_batch_size: u32, -} - // ============================================================================ // Schema Version Tracking (Issue #309) // ============================================================================ diff --git a/contract/contracts/hello-world/src/base/reputation.rs b/contract/contracts/hello-world/src/base/reputation.rs index 4b28d33..1656642 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -126,6 +126,7 @@ impl SenderReputation { #[cfg(test)] mod tests { use super::*; + use soroban_sdk::testutils::Address as _; #[test] fn test_reputation_tier_classification() { @@ -156,7 +157,8 @@ mod tests { #[test] fn test_sender_reputation_tracking() { - let sender = Address::random(&Default::default()); + let env = Env::default(); + let sender = Address::generate(&env); let mut rep = SenderReputation::new(sender.clone(), 1000); assert_eq!(rep.reputation_score, INITIAL_REPUTATION_SCORE); diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index a9f86e3..bcc51ef 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -392,6 +392,8 @@ impl AutoShareContract { /// Emits a `BatchProcessingCompleted` event for off-chain listeners. pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) { autoshare_logic::emit_batch_completed(env, batch_id, processed_count).unwrap(); + } + // ============================================================================ // Batch Notification Creation // ============================================================================ @@ -520,7 +522,7 @@ impl AutoShareContract { /// Record a failed notification delivery for a sender. /// Decreases the sender's reputation score based on delivery history. - pub fn record_delivery_failure(env: Env, sender: Address) { + pub fn record_reputation_failure(env: Env, sender: Address) { reputation_logic::record_failed_delivery(&env, &sender).unwrap(); } @@ -638,4 +640,7 @@ mod tests { #[path = "../tests/access_log_test.rs"] mod access_log_test; + + #[path = "../tests/event_emission_test.rs"] + mod event_emission_test; } diff --git a/contract/contracts/hello-world/src/reputation_logic.rs b/contract/contracts/hello-world/src/reputation_logic.rs index 08142cd..2172b52 100644 --- a/contract/contracts/hello-world/src/reputation_logic.rs +++ b/contract/contracts/hello-world/src/reputation_logic.rs @@ -1,13 +1,11 @@ +use crate::autoshare_logic::DataKey; use crate::base::events::{NotificationCategory, NotificationPriority, ReputationUpdated, ReputationTierChanged}; -use crate::base::reputation::{SenderReputation, INITIAL_REPUTATION_SCORE}; -use soroban_sdk::{Address, Env, Symbol, storage::Persistent, String as SorobanString, Error}; - -const REPUTATION_KEY_PREFIX: &str = "reputation_"; +use crate::base::reputation::SenderReputation; +use soroban_sdk::{Address, Env, Error}; /// Get the storage key for a sender's reputation. -fn reputation_key(sender: &Address) -> SorobanString { - let key_str = format!("{}{}", REPUTATION_KEY_PREFIX, sender); - SorobanString::from_small_str(&key_str) +fn reputation_key(sender: &Address) -> DataKey { + DataKey::Reputation(sender.clone()) } /// Initialize or get a sender's reputation record. @@ -41,31 +39,27 @@ pub fn record_successful_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - env.events().publish( - ("rep_update",), - ReputationUpdated { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - }, - ); + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + } + .publish(env); // Emit tier change event if tier changed if old_tier != new_tier { - env.events().publish( - ("rep_tier_change",), - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - }, - ); + ReputationTierChanged { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + old_tier: old_tier as u32, + new_tier: new_tier as u32, + reputation_score: reputation.reputation_score, + } + .publish(env); } Ok(()) @@ -88,31 +82,27 @@ pub fn record_failed_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - env.events().publish( - ("rep_update",), - ReputationUpdated { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - }, - ); + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + } + .publish(env); // Emit tier change event if tier changed if old_tier != new_tier { - env.events().publish( - ("rep_tier_change",), - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - }, - ); + ReputationTierChanged { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + old_tier: old_tier as u32, + new_tier: new_tier as u32, + reputation_score: reputation.reputation_score, + } + .publish(env); } Ok(()) diff --git a/contract/contracts/hello-world/src/tests/event_emission_test.rs b/contract/contracts/hello-world/src/tests/event_emission_test.rs new file mode 100644 index 0000000..2146c94 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/event_emission_test.rs @@ -0,0 +1,742 @@ +//! Dedicated event-emission verification suite (Issue #291). +//! +//! Unlike the per-feature test files (which mostly assert one topic/field at a +//! time), every assertion here compares the **entire** logged event tuple +//! (contract address, full topic list, full data payload) against the tuple +//! produced by constructing the expected event struct and calling its +//! macro-generated `topics()`/`data()` methods. That makes every assertion +//! sensitive to: +//! - a field being added, removed, renamed, or reordered, +//! - a field's type changing, +//! - a topic becoming data (or vice versa), +//! - the relative order of events emitted within a single transaction. +//! +//! Sections: +//! 1. Structural coverage — one positive test per event type, asserting the +//! full event tuple. +//! 2. Ordering — transactions that emit multiple events, asserting the exact +//! sequence. +//! 3. Negative cases — transactions that fail validation/authorization must +//! not leave behind partial or unexpected events. + +extern crate std; + +use crate::base::events::{ + AdminTransferred, AuditAction, AuditRecordAppended, AutoshareCreated, + AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, + ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, + NotificationCategory, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, + NotificationPriority, NotificationRevoked, NotificationScheduled, + ScheduledNotificationCancelled, SchemaVersionSet, Withdrawal, +}; +use crate::base::reputation::ReputationTier; +use crate::base::types::GroupMember; +use crate::test_utils::{create_test_group, mint_tokens, setup_test_env}; +use crate::AutoShareContractClient; + +use soroban_sdk::testutils::{Address as _, Events, Ledger}; +use soroban_sdk::{Address, BytesN, Env, Event, String, Val, Vec}; + +fn nid(env: &Env, tag: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = tag; + BytesN::from_array(env, &bytes) +} + +fn title(env: &Env) -> String { + String::from_str(env, "Test notification") +} + +/// Bare `Val` (the raw event `data` payload) doesn't implement `PartialEq` — +/// only soroban_sdk's typed wrappers (like `Vec`) do, via host-level +/// structural comparison. Wrapping the lone `data` value in a singleton +/// `Vec` lets us compare it (and therefore the *whole* logged event) +/// with a real equality check instead of manually picking apart fields. +/// +/// Builds the comparable `(contract_address, topics, [data])` triple that +/// corresponds to what `env.events().all()` logs for `event`. +fn expected_event(env: &Env, contract_id: &Address, event: &impl Event) -> (Address, Vec, Vec) { + ( + contract_id.clone(), + event.topics(env), + Vec::from_array(env, [event.data(env)]), + ) +} + +/// Snapshots every event actually emitted so far, in the same comparable +/// shape produced by [`expected_event`]. +fn actual_events(env: &Env) -> std::vec::Vec<(Address, Vec, Vec)> { + env.events() + .all() + .iter() + .map(|(addr, topics, data)| (addr, topics, Vec::from_array(env, [data]))) + .collect() +} + +// ============================================================================ +// 1. Structural coverage — one positive test per event type +// ============================================================================ + +#[test] +fn autoshare_created_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + mint_tokens(env, &token, &creator, 1_000); + + let id = nid(env, 1); + client.create(&id, &title(env), &creator, &1u32, &token); + + let expected = AutoshareCreated { + creator: creator.clone(), + category: NotificationCategory::Group, + priority: NotificationPriority::Medium, + id: id.clone(), + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn category_registered_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + + client.register_category(&test_env.admin, &NotificationCategory::Financial); + + let expected = CategoryRegistered { + admin: test_env.admin.clone(), + category: NotificationCategory::Financial, + priority: NotificationPriority::Medium, + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn contract_paused_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + + client.pause(&test_env.admin); + + let expected = ContractPaused { + admin: test_env.admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::High, + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn contract_unpaused_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + + client.pause(&test_env.admin); + client.unpause(&test_env.admin); + + let expected = ContractUnpaused { + admin: test_env.admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::High, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn autoshare_updated_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + + let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); + + let mut members = Vec::new(env); + members.push_back(GroupMember { + address: Address::generate(env), + percentage: 100, + }); + client.update_members(&id, &creator, &members); + + let expected = AutoshareUpdated { + updater: creator.clone(), + category: NotificationCategory::Group, + priority: NotificationPriority::Medium, + id: id.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn group_deactivated_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + + let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); + client.deactivate_group(&id, &creator); + + let expected = GroupDeactivated { + creator: creator.clone(), + category: NotificationCategory::Group, + priority: NotificationPriority::Low, + id: id.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn group_activated_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + + let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); + client.deactivate_group(&id, &creator); + client.activate_group(&id, &creator); + + let expected = GroupActivated { + creator: creator.clone(), + category: NotificationCategory::Group, + priority: NotificationPriority::Low, + id: id.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn admin_transferred_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let new_admin = Address::generate(env); + + client.transfer_admin(&test_env.admin, &new_admin); + + let expected = AdminTransferred { + old_admin: test_env.admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::Critical, + new_admin: new_admin.clone(), + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn withdrawal_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + let recipient = Address::generate(env); + + // 5 usages * 10 fee = 50 tokens land in the contract. + create_test_group(env, cid, &creator, &Vec::new(env), 5, &token); + + client.withdraw(&test_env.admin, &token, &20i128, &recipient); + + let expected = Withdrawal { + token: token.clone(), + recipient: recipient.clone(), + category: NotificationCategory::Financial, + priority: NotificationPriority::High, + amount: 20i128, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn notification_scheduled_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 9); + + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + + let expected = NotificationScheduled { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + notification_id: id.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn notification_expired_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 10); + + env.ledger().set_timestamp(1_000); + client.schedule_notification(&id, &creator, &10u64, &title(env)); + env.ledger().set_timestamp(1_011); + client.expire_notification(&id); + + let expected = NotificationExpired { + notification_id: id.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + expires_at: 1_010, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn scheduled_notification_cancelled_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 11); + + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + client.cancel_notification(&id, &creator); + + let expected = ScheduledNotificationCancelled { + caller: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Low, + notification_id: id.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn audit_record_appended_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let relay = Address::generate(env); + let id = nid(env, 12); + + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + client.record_delivery_attempt(&id, &relay); + + let expected = AuditRecordAppended { + notification_id: id.clone(), + action: AuditAction::DeliveryAttempt, + category: NotificationCategory::Notification, + seq: 2, + actor: relay.clone(), + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn notification_revoked_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 13); + + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + client.revoke_notification(&id, &creator); + + let expected = NotificationRevoked { + notification_id: id.clone(), + revoked_by: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn batch_processing_completed_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let batch_id = nid(env, 14); + + client.emit_batch_completed(&batch_id, &7u32); + + let expected = BatchProcessingCompleted { + batch_id: batch_id.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + processed_count: 7, + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn notification_extended_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 15); + + env.ledger().set_timestamp(1_000); + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + client.extend_notification_expiry(&id, &creator, &600u64); + + let expected = NotificationExtended { + notification_id: id.clone(), + caller: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_expires_at: 1_000 + 3_600 + 600, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn notification_limits_configured_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + + client.configure_notification_limits(&test_env.admin, &1_024u32, &86_400u64, &60u64, &25u32); + + let expected = NotificationLimitsConfigured { + admin: test_env.admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::Medium, + max_payload_size: 1_024, + max_expiration_seconds: 86_400, + min_expiration_seconds: 60, + max_batch_size: 25, + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn schema_version_set_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + + client.set_schema_version(&test_env.admin, &1u32); + + let expected = SchemaVersionSet { + admin: test_env.admin.clone(), + category: NotificationCategory::Admin, + priority: NotificationPriority::Medium, + schema_version: 1, + previous_version: 0, + }; + assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); +} + +#[test] +fn notification_accessed_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let accessor = Address::generate(env); + let id = nid(env, 16); + + env.ledger().set_timestamp(2_000); + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + client.record_notification_access(&id, &accessor); + + let expected = NotificationAccessed { + notification_id: id.clone(), + accessor: accessor.clone(), + category: NotificationCategory::Notification, + accessed_at: 2_000, + }; + assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); +} + +#[test] +fn reputation_updated_and_tier_changed_structural() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let sender = Address::generate(env); + + // A brand-new sender starts at the initial score (50 → Bronze). One + // successful delivery pushes the score to 75 → Silver, so this single + // call exercises both ReputationUpdated *and* ReputationTierChanged + // (and proves the update is logged before the tier change). Note: the + // events are checked *before* any further client calls — each + // invocation's event log is independent, so a later read-only call + // would otherwise make this call's events disappear from view. + client.record_delivery_success(&sender); + + use crate::base::events::{ReputationTierChanged, ReputationUpdated}; + let expected_update = ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: 75, + successful_count: 1, + failed_count: 0, + }; + let expected_tier = ReputationTierChanged { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + old_tier: ReputationTier::Bronze as u32, + new_tier: ReputationTier::Silver as u32, + reputation_score: 75, + }; + assert_eq!( + actual_events(env), + std::vec![ + expected_event(env, cid, &expected_update), + expected_event(env, cid, &expected_tier), + ] + ); + + let rep = client.get_sender_reputation(&sender); + assert_eq!(rep.reputation_score, 75); + assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Silver as u32); +} + +// ============================================================================ +// 2. Ordering — multi-event transactions +// ============================================================================ + +/// `batch_schedule_notifications` must emit one `NotificationScheduled` per +/// notification id, strictly in input order, followed by exactly one +/// `BatchNotificationsCreated` summary event — never interleaved or reordered. +#[test] +fn batch_schedule_emits_events_in_order() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + + let mut ids = Vec::new(env); + let mut ttls = Vec::new(env); + let mut titles = Vec::new(env); + for tag in [20u8, 21, 22] { + ids.push_back(nid(env, tag)); + ttls.push_back(3_600u64); + titles.push_back(title(env)); + } + + client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); + + let mut expected: std::vec::Vec<_> = std::vec::Vec::new(); + for (i, id) in ids.iter().enumerate() { + let audit = AuditRecordAppended { + notification_id: id.clone(), + action: AuditAction::Created, + category: NotificationCategory::Notification, + seq: (i + 1) as u64, + actor: creator.clone(), + }; + expected.push(expected_event(env, cid, &audit)); + + let scheduled = NotificationScheduled { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + notification_id: id.clone(), + }; + expected.push(expected_event(env, cid, &scheduled)); + } + let summary = BatchNotificationsCreated { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + count: 3, + ids: ids.clone(), + }; + expected.push(expected_event(env, cid, &summary)); + + assert_eq!(actual_events(env), expected); +} + +/// `schedule_notification` appends an audit record *before* announcing the +/// notification — both events come from the same invocation, so their +/// relative order is exactly what off-chain consumers will see. +#[test] +fn schedule_notification_emits_audit_then_scheduled_in_order() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let id = nid(env, 23); + + client.schedule_notification(&id, &creator, &3_600u64, &title(env)); + + let scheduled_audit = AuditRecordAppended { + notification_id: id.clone(), + action: AuditAction::Created, + category: NotificationCategory::Notification, + seq: 1, + actor: creator.clone(), + }; + let scheduled = NotificationScheduled { + creator: creator.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + notification_id: id.clone(), + }; + + let expected = std::vec![ + expected_event(env, cid, &scheduled_audit), + expected_event(env, cid, &scheduled), + ]; + assert_eq!(actual_events(env), expected); +} + +// ============================================================================ +// 3. Negative cases — failed transactions must not emit (partial) events +// ============================================================================ + +#[test] +fn create_with_zero_usage_emits_no_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + + let result = client.try_create(&nid(env, 30), &title(env), &creator, &0u32, &token); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} + +// Note: the test `Env`'s event log (`env.events().all()`) is scoped to the +// *most recent* top-level contract invocation, mirroring real ledger +// semantics where a failed/reverted call's events never commit and a fresh +// call starts from a clean slate. So "no event on failure" is asserted as +// `is_empty()` right after the failing call, not as "unchanged from before". + +#[test] +fn update_members_bad_percentages_emits_no_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + + let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); + + let mut members = Vec::new(env); + members.push_back(GroupMember { + address: Address::generate(env), + percentage: 40, // doesn't sum to 100 + }); + let result = client.try_update_members(&id, &creator, &members); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} + +#[test] +fn pause_while_already_paused_emits_no_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); + + client.pause(&test_env.admin); + + let result = client.try_pause(&test_env.admin); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} + +#[test] +fn schedule_notification_zero_ttl_emits_no_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap(); + + let result = client.try_schedule_notification(&nid(env, 31), &creator, &0u64, &title(env)); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} + +#[test] +fn register_category_twice_emits_no_event_on_second_call() { + let test_env = setup_test_env(); + let env = &test_env.env; + let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); + + client.register_category(&test_env.admin, &NotificationCategory::Group); + + let result = client.try_register_category(&test_env.admin, &NotificationCategory::Group); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} + +/// `transfer_admin` calls `publish_authorization_failure` *before* returning +/// `Error::Unauthorized` — but every contract entry point in `lib.rs` calls +/// `.unwrap()` on that `Result`, turning the `Err` into a panic. A panicking +/// invocation reverts the whole transaction, including any events it +/// published before the panic point. So `AuthorizationFailure` (like any +/// event emitted on a path that ends in an error) is never actually +/// observable through the public contract interface as currently wired — +/// this test locks in that behavior so a future refactor that changes it +/// (e.g. switching to non-panicking entry points) gets caught. +#[test] +fn unauthorized_transfer_admin_reverts_with_no_observable_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); + let impostor = Address::generate(env); + let new_admin = Address::generate(env); + + let result = client.try_transfer_admin(&impostor, &new_admin); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); + + // The admin must be unchanged — confirming AdminTransferred never fired. + assert_eq!(client.get_admin(), test_env.admin); +} + +#[test] +fn withdraw_exceeding_balance_emits_no_event() { + let test_env = setup_test_env(); + let env = &test_env.env; + let cid = &test_env.autoshare_contract; + let client = AutoShareContractClient::new(env, cid); + let creator = test_env.users.get(0).unwrap(); + let token = test_env.mock_tokens.get(0).unwrap(); + let recipient = Address::generate(env); + + create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); // 10 tokens in contract + + let result = client.try_withdraw(&test_env.admin, &token, &1_000_000i128, &recipient); + assert!(result.is_err()); + assert!(env.events().all().is_empty()); +} diff --git a/contract/contracts/hello-world/src/tests/notification_validation_test.rs b/contract/contracts/hello-world/src/tests/notification_validation_test.rs index 1f5c185..2cce092 100644 --- a/contract/contracts/hello-world/src/tests/notification_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_validation_test.rs @@ -19,10 +19,16 @@ use soroban_sdk::{Address, BytesN, String, TryFromVal, Vec}; // ─── helpers ──────────────────────────────────────────────────────────────── +/// Categories are the **second-to-last** topic (priority is the trailing +/// topic), so this reads from the back accordingly. fn last_category(env: &soroban_sdk::Env) -> Option { let (_addr, topics, _data) = env.events().all().last()?; - let last = topics.last()?; - NotificationCategory::try_from_val(env, &last).ok() + let n = topics.len(); + if n < 2 { + return None; + } + let category_topic = topics.get(n - 2)?; + NotificationCategory::try_from_val(env, &category_topic).ok() } // ── create: invalid payload — zero usage count ─────────────────────────────── diff --git a/listener/API.md b/listener/API.md index 6f67a1f..8e3157e 100644 --- a/listener/API.md +++ b/listener/API.md @@ -758,8 +758,8 @@ Retry-After: 12 X-Request-Id: 8b4c3d2e-... { - "error": "Too Many Requests", - "message": "Rate limit exceeded. Try again in 12 seconds." + "error": "TooManyRequests", + "message": "Rate limit exceeded. Please try again later." } ``` diff --git a/listener/RATE-LIMITING-GUIDE.md b/listener/RATE-LIMITING-GUIDE.md index e16e3db..c482cc8 100644 --- a/listener/RATE-LIMITING-GUIDE.md +++ b/listener/RATE-LIMITING-GUIDE.md @@ -115,8 +115,8 @@ Retry-After: 45 Content-Type: application/json { - "error": "Too Many Requests", - "message": "Rate limit exceeded. Try again in 45 seconds." + "error": "TooManyRequests", + "message": "Rate limit exceeded. Please try again later." } ``` diff --git a/listener/src/api/rate-limiter.test.ts b/listener/src/api/rate-limiter.test.ts index 9b3e6bd..97acbd0 100644 --- a/listener/src/api/rate-limiter.test.ts +++ b/listener/src/api/rate-limiter.test.ts @@ -189,8 +189,8 @@ describe('RateLimiter', () => { expect(res3._getHeaders().get('retry-after')).toBeDefined(); const body = JSON.parse(res3._getBody()); - expect(body.error).toBe('Too Many Requests'); - expect(body.message).toContain('Rate limit exceeded'); + expect(body.error).toBe('TooManyRequests'); + expect(body.message).toBe('Rate limit exceeded. Please try again later.'); limiter.destroy(); }); diff --git a/listener/src/api/rate-limiter.ts b/listener/src/api/rate-limiter.ts index 28fe9dd..7ea9698 100644 --- a/listener/src/api/rate-limiter.ts +++ b/listener/src/api/rate-limiter.ts @@ -155,8 +155,8 @@ export class RateLimiter { res.writeHead(429, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ - error: 'Too Many Requests', - message: `Rate limit exceeded. Try again in ${waitSec} seconds.`, + error: 'TooManyRequests', + message: 'Rate limit exceeded. Please try again later.', }) ); return false; diff --git a/listener/src/index.ts b/listener/src/index.ts index c9b6c14..ab66698 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -153,6 +153,8 @@ async function main() { if (reconciliationEngine) { reconciliationEngine.stop(); + } + if (metricsRunner) { await metricsRunner.stop(); } diff --git a/listener/src/services/batch-validation-service.ts b/listener/src/services/batch-validation-service.ts index ea1fa0f..1526780 100644 --- a/listener/src/services/batch-validation-service.ts +++ b/listener/src/services/batch-validation-service.ts @@ -187,7 +187,7 @@ export class BatchValidationService { } else { invalidEntries.push({ index, - error: validation.error, + error: validation.error ?? 'Invalid entry', }); } }); diff --git a/listener/src/store/event-registry.ts b/listener/src/store/event-registry.ts index 6d37485..24d5c3f 100644 --- a/listener/src/store/event-registry.ts +++ b/listener/src/store/event-registry.ts @@ -29,10 +29,6 @@ export class EventRegistry { this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs); } - setTtlMs(ms: number): void { - this.ttlMs = ms; - } - stopCleanup(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); From 0271652066960bba7cbf2b15993106ab6f50deb0 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Tue, 30 Jun 2026 14:56:35 +0100 Subject: [PATCH 2/2] Skip Cloudflare preview deploy gracefully when secrets are unavailable Fork PRs never receive repo secrets on the pull_request trigger, so CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID are empty and wrangler hard-fails the whole CI check for every external contributor. Skip the deploy step when those secrets aren't present and note it in the PR comment instead of failing the job. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/preview.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 84d9f4f..2812437 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -38,8 +38,18 @@ jobs: VITE_STELLAR_NETWORK: TESTNET run: npm run build + - name: Check for Cloudflare credentials + id: cf_check + run: | + if [ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" ] && [ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then + echo "has_creds=true" >> "$GITHUB_OUTPUT" + else + echo "has_creds=false" >> "$GITHUB_OUTPUT" + fi + - name: Deploy to Cloudflare Pages id: deploy + if: steps.cf_check.outputs.has_creds == 'true' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -49,6 +59,7 @@ jobs: - name: Post preview URL comment uses: actions/github-script@v7 env: + HAS_CREDS: ${{ steps.cf_check.outputs.has_creds }} DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} PR_NUMBER: ${{ github.event.pull_request.number }} COMMIT_SHA: ${{ github.event.pull_request.head.sha }} @@ -64,15 +75,16 @@ jobs: const existing = comments.find(c => c.body.includes(marker)); const short = process.env.COMMIT_SHA.slice(0, 7); + const hasCreds = process.env.HAS_CREDS === 'true'; const body = [ marker, '## 🚀 Preview Deployment', '', `| | |`, `|---|---|`, - `| **URL** | ${process.env.DEPLOYMENT_URL} |`, + `| **URL** | ${hasCreds ? process.env.DEPLOYMENT_URL : '_unavailable for this PR_'} |`, `| **Commit** | \`${short}\` |`, - `| **Status** | ✅ Ready |`, + `| **Status** | ${hasCreds ? '✅ Ready' : '⚠️ Skipped — Cloudflare credentials are not available to PRs from forks'} |`, '', '_This comment is updated automatically on every push to the PR._', ].join('\n');