diff --git a/README.md b/README.md index 7bf4593..bedb26b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ Run the comprehensive test suite locally: Deterministic example outputs for downstream SDK, dashboard, and indexer repos live in [`fixtures/sdk/`](fixtures/sdk). They are generated from real contract invocations and re-verified on every test run, so contract drift -fails CI here instead of reaching consumers: +fails CI here instead of reaching consumers. Event fixtures also assert the +exported typed payload, exact topic, caller, count, and ordering before their +JSON/XDR snapshots can be updated: ```bash make test-fixtures # verify committed fixtures still match make update-fixtures # regenerate after an intentional change diff --git a/docs/sdk-fixtures.md b/docs/sdk-fixtures.md index a067687..feabd86 100644 --- a/docs/sdk-fixtures.md +++ b/docs/sdk-fixtures.md @@ -7,6 +7,12 @@ capability reads — published for use by other repositories. The fixtures live in [`fixtures/sdk/`](../fixtures/sdk) and are generated and verified by [`tests/sdk_fixtures.rs`](../tests/sdk_fixtures.rs). +These fixtures verify protocol behavior and wire compatibility. They do not +determine whether an investor, asset, transfer, or deployment satisfies any +law, regulation, investment requirement, or financial objective. Legal and +financial determinations remain off-chain responsibilities; see the +[`Legal Boundary Disclaimer`](legal-boundary-disclaimer.md). + ## Why these exist SDK, dashboard, and indexer repos all need to know the exact shape of a @@ -40,7 +46,24 @@ Determinism comes from three choices: host-generated one. 2. Every actor address is a **fixed synthetic strkey** (see below). 3. Fixtures are rendered with a small, insertion-ordered JSON writer, so byte - output is stable across runs, machines, and Rust versions. + output is stable across runs, machines, and Rust versions. Verification + treats Git's Windows CRLF checkout conversion as equivalent to canonical + LF; all JSON tokens, ordering, values, and XDR bytes are still compared + exactly. + +Event fixtures add three independent guards: + +1. Each live event sequence is compared against exported, typed Rust payload + structs before it is serialized. This checks topic, contract address, + caller, payload, event count, and ordering. +2. A source coverage guard requires every emitted contract topic to appear in + `04-events.json`. +3. The rendered JSON and raw `ContractEvent` XDR are compared with the + committed fixture. + +As a result, update mode cannot silently approve an accidental event schema +change: the contributor must intentionally update the typed expectation +before a new snapshot can be written. ## The two modes @@ -48,8 +71,8 @@ The harness both publishes and guards the fixtures. ```bash # Verify (the default, and what CI runs): -# regenerate every scenario and compare byte-for-byte against the -# committed files. Contract drift fails here. +# regenerate every scenario and compare exactly against the committed +# files (after CRLF/LF normalization). Contract drift fails here. cargo test --test sdk_fixtures # Update: rewrite the committed fixtures after an intentional change. @@ -252,15 +275,20 @@ a contract change cannot silently alter your test expectations, and key off 1. Add it to the relevant `fixture_*` test in `tests/sdk_fixtures.rs`, driving the real client rather than constructing values by hand. -2. Use `Harness::render` for return values and `Harness::events` for events, - so output is derived from wire-level XDR. -3. Assert the behaviour in Rust as well (`assert_eq!`) — the fixture records - what happened, the assertion states what *should* happen, and having both - means a wrong fixture cannot quietly become the new expected value. +2. Use `Harness::render` for return values. For events, construct the exported + payload struct with `typed_events!`; this asserts the exact typed sequence + and then renders the same live events from wire-level XDR. +3. For an expected silent path, assert the specific failure first and then use + `Harness::assert_no_events`. Never model a reverted compliance action as an + emitted "rejection" event: Soroban discards events from reverted calls. 4. Give it a unique, stable `id`; ids are the downstream addressing key and renaming one is a breaking change. -5. Regenerate with `UPDATE_FIXTURES=1`, review the diff, and commit the - updated JSON alongside the test change. +5. If event behavior intentionally changes, update the typed expectation and + [`docs/events.md`](events.md) in the same change. Explain the SDK/dashboard + compatibility impact in the PR. +6. Regenerate with `UPDATE_FIXTURES=1`, review the diff, and commit the + updated JSON alongside the test change. Update mode is not an approval + mechanism; a changed snapshot still requires review. Use only the actors in `00-actors.json`. Adding a new one means extending the `ACTORS` table in `tests/support/mod.rs` with a strkey derived from the diff --git a/docs/testing-standards.md b/docs/testing-standards.md index e4ae445..85c89de 100644 --- a/docs/testing-standards.md +++ b/docs/testing-standards.md @@ -265,6 +265,13 @@ diff carefully and commit it alongside your logic change. The CI gate (`make test-fixtures`) will fail if the committed fixtures drift from actual behaviour. +For event changes, regeneration alone is insufficient. Add or update the +`typed_events!` expectation in `tests/sdk_fixtures.rs` so the live topic, +exported payload type, caller fields, count, and ordering are asserted before +JSON/XDR is written. For reverted calls, assert the exact error and use +`Harness::assert_no_events`. This prevents update mode from blessing an +accidental schema or compliance-audit regression. + See [`docs/sdk-fixtures.md`](sdk-fixtures.md) for the format specification. ### Manual Verification diff --git a/tests/sdk_fixtures.rs b/tests/sdk_fixtures.rs index 5f04a39..53dd209 100644 --- a/tests/sdk_fixtures.rs +++ b/tests/sdk_fixtures.rs @@ -28,17 +28,50 @@ mod support; -use soroban_sdk::{Address, String as SorobanString}; +use soroban_sdk::{Address, IntoVal, String as SorobanString}; -use aegis_contracts::compliance::{ComplianceBatchUpdate, ComplianceStatus}; -use aegis_contracts::lifecycle::AssetStatus; -use aegis_contracts::{Error, Role}; +use aegis_contracts::admin::{ + AdminTransferInitiatedEvent, AdminTransferredEvent, ContractPausedEvent, ContractUnpausedEvent, + RoleAssignedEvent, RoleRevokedEvent, +}; +use aegis_contracts::asset::{ + AssetMetadataUpdatedEvent, AssetMintedEvent, TransferEvent, YieldDistributedEvent, +}; +use aegis_contracts::compliance::{ + ComplianceBatchUpdate, ComplianceStatus, ComplianceStatusChangedEvent, UserWhitelistedEvent, + WhitelistRevokedEvent, +}; +use aegis_contracts::config::{ConfigAmendedEvent, ConfigProposedEvent, ProtocolConfig}; +use aegis_contracts::holding::{HoldingCapAmendedEvent, HoldingCapProposedEvent}; +use aegis_contracts::lifecycle::{AssetStatus, AssetStatusChangedEvent}; +use aegis_contracts::supply_cap::{SupplyCapAmendedEvent, SupplyCapProposedEvent}; +use aegis_contracts::{ContractInitializedEvent, Error, Role}; use support::{ assert_unique_ids, envelope, write_or_verify, Harness, Json, JsonObj, Scenario, ACTORS, ADDRESS_DERIVATION_SEED, CONTRACT_ADDRESS, }; +/// Compile-time typed event expectation used by the fixture generator. +/// +/// The payload expression must construct the contract's exported event type. +/// The resulting values are compared to the live Soroban event sequence +/// before that sequence is serialized to JSON/XDR, so update mode cannot +/// silently bless a schema or ordering regression. +macro_rules! typed_events { + ($harness:expr, $(($topic:literal, $payload:expr)),+ $(,)?) => {{ + let h = &$harness; + h.assert_events(soroban_sdk::vec![ + &h.env, + $(( + h.contract_id.clone(), + ($topic,).into_val(&h.env), + $payload.into_val(&h.env), + )),+ + ]) + }}; +} + // ─── Helpers ────────────────────────────────────────────────────────────────── /// Renders the result of a `try_*` client call as a fixture value. @@ -974,14 +1007,50 @@ fn fixture_events() { push_event( "event-user-whitelisted", "Topic `user_whitelisted`, emitted by `whitelist_user`.", - h.events(), + typed_events!( + h, + ( + "compliance_status_changed", + ComplianceStatusChangedEvent { + caller: h.actor("compliance_officer"), + user: h.actor("investor_carol"), + previous_status: ComplianceStatus::Unknown, + new_status: ComplianceStatus::Approved, + } + ), + ( + "user_whitelisted", + UserWhitelistedEvent { + caller: h.actor("compliance_officer"), + user: h.actor("investor_carol"), + } + ), + ), ); c.revoke_whitelist(&h.actor("compliance_officer"), &h.actor("investor_carol")); push_event( "event-whitelist-revoked", "Topic `whitelist_revoked`, emitted by `revoke_whitelist`.", - h.events(), + typed_events!( + h, + ( + "compliance_status_changed", + ComplianceStatusChangedEvent { + caller: h.actor("compliance_officer"), + user: h.actor("investor_carol"), + previous_status: ComplianceStatus::Approved, + new_status: ComplianceStatus::Revoked, + } + ), + ( + "whitelist_revoked", + WhitelistRevokedEvent { + caller: h.actor("compliance_officer"), + user: h.actor("investor_carol"), + } + ), + ), ); } @@ -997,14 +1066,35 @@ fn fixture_events() { push_event( "event-asset-minted", "Topic `asset_minted`. `total_supply` is cumulative across all holders.", - h.events(), + typed_events!( + h, + ( + "asset_minted", + AssetMintedEvent { + caller: h.actor("asset_manager"), + to: h.actor("investor_alice"), + amount: 1_000, + total_supply: 1_000, + } + ), + ), ); c.transfer(&h.actor("investor_alice"), &h.actor("investor_bob"), &250); push_event( "event-transfer", "Topic `transfer`, emitted on every successful transfer.", - h.events(), + typed_events!( + h, + ( + "transfer", + TransferEvent { + from: h.actor("investor_alice"), + to: h.actor("investor_bob"), + amount: 250, + } + ), + ), ); c.distribute_yield(&h.actor("asset_manager"), &500); @@ -1012,7 +1102,16 @@ fn fixture_events() { "event-yield-distributed", "Topic `yield_distributed`. The current implementation is a mock that \ emits the event without moving balances (see asset.rs).", - h.events(), + typed_events!( + h, + ( + "yield_distributed", + YieldDistributedEvent { + caller: h.actor("asset_manager"), + amount: 500, + } + ), + ), ); } @@ -1024,7 +1123,15 @@ fn fixture_events() { push_event( "event-contract-initialized", "Topic `contract_initialized`, emitted once on single-initialization.", - h.events(), + typed_events!( + h, + ( + "contract_initialized", + ContractInitializedEvent { + admin: h.actor("admin"), + } + ), + ), ); c.set_role( @@ -1036,28 +1143,66 @@ fn fixture_events() { "event-role-assigned", "Topic `role_assigned`. The `role` field is a unit enum, encoded as a \ single-element vector holding the variant name.", - h.events(), + typed_events!( + h, + ( + "role_assigned", + RoleAssignedEvent { + admin: h.actor("admin"), + target: h.actor("compliance_officer"), + role: Role::ComplianceOfficer, + } + ), + ), ); c.remove_role(&h.actor("admin"), &h.actor("compliance_officer")); push_event( "event-role-revoked", "Topic `role_revoked`. `role` carries the *previous* role, not None.", - h.events(), + typed_events!( + h, + ( + "role_revoked", + RoleRevokedEvent { + admin: h.actor("admin"), + target: h.actor("compliance_officer"), + role: Role::ComplianceOfficer, + } + ), + ), ); c.transfer_admin(&h.actor("admin"), &h.actor("investor_carol")); push_event( "event-admin-transfer-initiated", "Topic `admin_transfer_initiated`, step 1 of the 2-step admin handoff.", - h.events(), + typed_events!( + h, + ( + "admin_transfer_initiated", + AdminTransferInitiatedEvent { + current_admin: h.actor("admin"), + candidate: h.actor("investor_carol"), + } + ), + ), ); c.accept_admin(&h.actor("investor_carol")); push_event( "event-admin-transferred", "Topic `admin_transferred`, step 2. The candidate must call `accept_admin`.", - h.events(), + typed_events!( + h, + ( + "admin_transferred", + AdminTransferredEvent { + previous_admin: h.actor("admin"), + new_admin: h.actor("investor_carol"), + } + ), + ), ); // `renounce_admin` reuses the `AdminTransferredEvent` payload but @@ -1071,7 +1216,16 @@ fn fixture_events() { It reuses the AdminTransferredEvent payload with `new_admin` equal to \ `previous_admin`, so the topic — not the payload — is what distinguishes \ a renounce from a transfer. After this the contract has no admin.", - h.events(), + typed_events!( + h, + ( + "admin_renounced", + AdminTransferredEvent { + previous_admin: h.actor("investor_carol"), + new_admin: h.actor("investor_carol"), + } + ), + ), ); } @@ -1084,14 +1238,30 @@ fn fixture_events() { "event-contract-paused", "Topic `contract_paused`. An EmergencyOfficer may pause; only the admin \ may unpause.", - h.events(), + typed_events!( + h, + ( + "contract_paused", + ContractPausedEvent { + admin: h.actor("emergency_officer"), + } + ), + ), ); c.unpause(&h.actor("admin")); push_event( "event-contract-unpaused", "Topic `contract_unpaused`, emitted by the admin-only `unpause`.", - h.events(), + typed_events!( + h, + ( + "contract_unpaused", + ContractUnpausedEvent { + admin: h.actor("admin"), + } + ), + ), ); } @@ -1105,28 +1275,68 @@ fn fixture_events() { push_event( "event-supply-cap-proposed", "Topic `supply_cap_proposed`, step 1 of supply cap governance.", - h.events(), + typed_events!( + h, + ( + "supply_cap_proposed", + SupplyCapProposedEvent { + admin: h.actor("admin"), + current_cap: 0, + proposed_cap: 10_000, + } + ), + ), ); c.accept_supply_cap(&admin); push_event( "event-supply-cap-amended", "Topic `supply_cap_amended`, step 2 — the cap is now enforced.", - h.events(), + typed_events!( + h, + ( + "supply_cap_amended", + SupplyCapAmendedEvent { + admin: h.actor("admin"), + previous_cap: 0, + new_cap: 10_000, + } + ), + ), ); c.propose_holding_cap(&admin, &2_000); push_event( "event-holding-cap-proposed", "Topic `holding_cap_proposed`, step 1 of holding cap governance.", - h.events(), + typed_events!( + h, + ( + "holding_cap_proposed", + HoldingCapProposedEvent { + admin: h.actor("admin"), + current_cap: 0, + proposed_cap: 2_000, + } + ), + ), ); c.accept_holding_cap(&admin); push_event( "event-holding-cap-amended", "Topic `holding_cap_amended`, step 2 — the per-investor cap is now enforced.", - h.events(), + typed_events!( + h, + ( + "holding_cap_amended", + HoldingCapAmendedEvent { + admin: h.actor("admin"), + previous_cap: 0, + new_cap: 2_000, + } + ), + ), ); } @@ -1136,7 +1346,7 @@ fn fixture_events() { let c = h.client(); let admin = h.actor("admin"); - let config = aegis_contracts::config::ProtocolConfig { + let config = ProtocolConfig { min_transfer_amount: 100, max_batch_size: 50, }; @@ -1145,14 +1355,32 @@ fn fixture_events() { push_event( "event-config-proposed", "Topic `config_proposed`, step 1 of protocol config governance.", - h.events(), + typed_events!( + h, + ( + "config_proposed", + ConfigProposedEvent { + admin: h.actor("admin"), + proposed_config: config.clone(), + } + ), + ), ); c.accept_config(&admin); push_event( "event-config-amended", "Topic `config_amended`, step 2 — the new config is now active.", - h.events(), + typed_events!( + h, + ( + "config_amended", + ConfigAmendedEvent { + admin: h.actor("admin"), + new_config: config, + } + ), + ), ); } @@ -1166,21 +1394,41 @@ fn fixture_events() { "event-asset-status-changed", "Topic `asset_status_changed`. Both statuses are unit enums encoded as \ single-element vectors.", - h.events(), + typed_events!( + h, + ( + "asset_status_changed", + AssetStatusChangedEvent { + admin: h.actor("admin"), + previous_status: AssetStatus::Active, + new_status: AssetStatus::Paused, + } + ), + ), ); c.set_asset_status(&h.actor("admin"), &AssetStatus::Active); - c.update_asset_metadata( - &h.actor("asset_manager"), - &SorobanString::from_str(&h.env, "Aegis Sample Tower"), - &SorobanString::from_str(&h.env, "AST"), - &SorobanString::from_str(&h.env, "https://example.invalid/aegis/sample-tower.json"), - ); + let name = SorobanString::from_str(&h.env, "Aegis Sample Tower"); + let symbol = SorobanString::from_str(&h.env, "AST"); + let uri = + SorobanString::from_str(&h.env, "https://example.invalid/aegis/sample-tower.json"); + c.update_asset_metadata(&h.actor("asset_manager"), &name, &symbol, &uri); push_event( "event-asset-metadata-updated", "Topic `asset_metadata_updated`. The URI is a documentation-only \ `example.invalid` host and resolves nowhere.", - h.events(), + typed_events!( + h, + ( + "asset_metadata_updated", + AssetMetadataUpdatedEvent { + caller: h.actor("asset_manager"), + name, + symbol, + uri, + } + ), + ), ); } @@ -1193,8 +1441,7 @@ fn fixture_events() { let result = c.try_transfer(&alice, &dave, &100); assert_eq!(result, Err(Ok(Error::ReceiverNotWhitelisted))); - let events = h.events(); - assert_eq!(events, Json::Arr(vec![])); + let events = h.assert_no_events(); scenarios.push( Scenario::new( diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 53b312c..5a8090d 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -151,6 +151,36 @@ impl Harness { } Json::Arr(out) } + + /// Asserts the exact typed event sequence, then renders the same live + /// events for the committed fixture. + /// + /// Keeping this assertion in the fixture-generation path matters: + /// `UPDATE_FIXTURES=1` must not be able to bless an accidental topic, + /// payload, caller, or ordering change merely by rewriting the JSON. + pub fn assert_events( + &self, + expected: soroban_sdk::Vec<(Address, soroban_sdk::Vec, Val)>, + ) -> Json { + assert_eq!( + self.env.events().all(), + expected, + "live event sequence does not match the exported typed fixture expectation" + ); + self.events() + } + + /// Asserts that the most recent invocation emitted no durable event, then + /// renders the empty sequence for a negative-path fixture. + pub fn assert_no_events(&self) -> Json { + let actual = self.env.events().all(); + assert_eq!( + actual.events().len(), + 0, + "reverted or read-only fixture invocation unexpectedly emitted an event" + ); + self.events() + } } // ─── ScVal → fixture JSON ───────────────────────────────────────────────────── @@ -455,7 +485,8 @@ pub fn update_mode() -> bool { } /// Writes `value` to `fixtures/sdk/` in update mode, or asserts that the -/// committed file already matches it byte-for-byte. +/// committed file already matches it byte-for-byte after normalizing checkout +/// line endings to the canonical `\n` representation. /// /// This is the mechanism that makes the fixtures *self-verifying*: if contract /// behaviour drifts (an event field is renamed, an error code changes, a @@ -482,7 +513,13 @@ pub fn write_or_verify(name: &str, value: &Json) { ) }); - if existing != rendered { + // Git may materialize a text fixture with CRLF on Windows even though the + // committed blob and deterministic renderer use LF. Treat that checkout + // transformation as equivalent; every JSON token, field order, value, and + // XDR byte remains subject to an exact comparison. + let existing_canonical = canonical_fixture_text(&existing); + + if existing_canonical != rendered { panic!( "fixture drift detected in {}\n\ The contract's observable behaviour no longer matches the committed fixture.\n\ @@ -491,12 +528,16 @@ pub fn write_or_verify(name: &str, value: &Json) { \nand review the diff before committing.\n\ \n--- committed ---\n{}\n--- generated ---\n{}", path.display(), - truncate(&existing), + truncate(&existing_canonical), truncate(&rendered) ); } } +fn canonical_fixture_text(text: &str) -> String { + text.replace("\r\n", "\n") +} + fn truncate(s: &str) -> String { const MAX: usize = 4000; if s.len() <= MAX { @@ -506,6 +547,24 @@ fn truncate(s: &str) -> String { } } +#[cfg(test)] +mod tests { + use super::canonical_fixture_text; + + #[test] + fn fixture_comparison_normalizes_only_windows_line_endings() { + assert_eq!( + canonical_fixture_text("{\r\n \"ok\": true\r\n}\r\n"), + "{\n \"ok\": true\n}\n" + ); + assert_ne!( + canonical_fixture_text("{\r\n \"ok\": false\r\n}\r\n"), + "{\n \"ok\": true\n}\n", + "normalization must not hide fixture content drift" + ); + } +} + /// Standard envelope wrapped around every fixture file. pub fn envelope(name: &str, purpose: &str, scenarios: Vec) -> Json { let mut obj = JsonObj::new();