diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a31d29..d4d2451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,15 @@ jobs: - name: Run tests working-directory: dashboard run: npm test --silent + - name: Run wallet integration tests + working-directory: dashboard + run: npm run test:wallet --silent + - name: Upload wallet test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: wallet-integration-report + path: dashboard/reports/wallet-integration.json listener: name: Listener (lint, typecheck, test) @@ -53,7 +62,7 @@ jobs: run: npm test --silent rust: - name: Rust (fmt check, tests) + name: Rust (fmt check, tests, fuzz) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,6 +77,16 @@ jobs: run: | rustup component add rustfmt || true cargo fmt --all -- --check - - name: Run tests + - name: Run unit tests working-directory: contract run: cargo test --workspace --all-features --verbose + - name: Run fuzz tests + working-directory: contract + run: cargo test fuzz_ --verbose -- --nocapture + - name: Generate fuzz coverage report + run: bash scripts/run-fuzz-coverage.sh + - name: Upload fuzz coverage report + uses: actions/upload-artifact@v4 + with: + name: fuzz-coverage-report + path: contract/reports/fuzz-coverage.json diff --git a/contract/Cargo.lock b/contract/Cargo.lock index 90eeac8..d8b67d2 100644 --- a/contract/Cargo.lock +++ b/contract/Cargo.lock @@ -147,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -174,6 +174,27 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -285,7 +306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -531,7 +552,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -556,7 +577,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -568,6 +589,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -580,13 +611,19 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -632,6 +669,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "group" version = "0.13.0" @@ -639,7 +699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -693,6 +753,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "hello-world" version = "0.0.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -837,6 +898,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -982,6 +1049,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.44" @@ -991,6 +1083,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -998,8 +1102,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1009,7 +1123,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1018,7 +1142,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1041,6 +1183,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -1060,12 +1208,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.8.22" @@ -1229,7 +1402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1294,7 +1467,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1302,8 +1475,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1356,7 +1529,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.5", "rustc_version", "serde", "serde_json", @@ -1531,6 +1704,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1588,6 +1774,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1611,12 +1803,30 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1758,6 +1968,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.34" diff --git a/contract/contracts/hello-world/Cargo.toml b/contract/contracts/hello-world/Cargo.toml index c3e84a9..c67c748 100644 --- a/contract/contracts/hello-world/Cargo.toml +++ b/contract/contracts/hello-world/Cargo.toml @@ -13,3 +13,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.6" diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index b7936bd..706fe10 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -431,4 +431,7 @@ mod tests { #[path = "../tests/revocation_test.rs"] mod revocation_test; + + #[path = "../tests/fuzz_test.rs"] + mod fuzz_test; } diff --git a/contract/contracts/hello-world/src/tests/fuzz_test.rs b/contract/contracts/hello-world/src/tests/fuzz_test.rs new file mode 100644 index 0000000..6420cdd --- /dev/null +++ b/contract/contracts/hello-world/src/tests/fuzz_test.rs @@ -0,0 +1,167 @@ +//! Property-based fuzz tests for AutoShare contract invariants. +//! +//! Targets: group member percentages, usage counts, notification TTL bounds, +//! and pause-state guards. Run via `cargo test fuzz_`. + +use crate::base::types::GroupMember; +use crate::test_utils::{create_test_group, mint_tokens, setup_test_env}; +use crate::AutoShareContractClient; +use proptest::prelude::*; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, BytesN, Env, String, Vec}; + +fn group_id(env: &Env, seed: u8) -> BytesN<32> { + BytesN::from_array(env, &[seed; 32]) +} + +fn build_members(env: &Env, percentages: &[u32]) -> Vec { + let mut members = Vec::new(env); + for pct in percentages { + members.push_back(GroupMember { + address: Address::generate(env), + percentage: *pct, + }); + } + members +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn fuzz_member_percentages_sum_to_100_succeed( + p1 in 1u32..=99u32, + ) { + let p2 = 100u32.saturating_sub(p1); + if p2 == 0 { + return Ok(()); + } + + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let members = build_members(&test_env.env, &[p1, p2]); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + mint_tokens(&test_env.env, &token, &creator, 10_000_000); + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + 1, + &token, + ); + + let group = client.get(&id); + prop_assert_eq!(group.members.len(), 2); + + let total: u32 = (0..group.members.len()) + .map(|i| group.members.get(i).unwrap().percentage) + .sum(); + prop_assert_eq!(total, 100); + } + + #[test] + fn fuzz_zero_usage_count_always_rejected(seed in 1u8..=200u8) { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + mint_tokens(&test_env.env, &token, &creator, 10_000_000); + + let id = group_id(&test_env.env, seed); + let name = String::from_str(&test_env.env, "Fuzz Group"); + + let result = client.try_create(&id, &name, &creator, &0u32, &token); + prop_assert!(result.is_err()); + } + + #[test] + fn fuzz_notification_ttl_positive_always_schedules( + ttl in 1u64..=86_400u64, + seed in 1u8..=200u8, + ) { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let notification_id = group_id(&test_env.env, seed); + + client.schedule_notification(¬ification_id, &creator, &ttl); + + let stored = client.get_notification(¬ification_id); + prop_assert_eq!(stored.expires_at - stored.created_at, ttl); + prop_assert!(!client.is_notification_expired(¬ification_id)); + } + + #[test] + fn fuzz_notification_zero_ttl_rejected(seed in 1u8..=200u8) { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let notification_id = group_id(&test_env.env, seed); + + let result = client.try_schedule_notification(¬ification_id, &creator, &0); + prop_assert!(result.is_err()); + } + + #[test] + fn fuzz_paused_contract_blocks_group_creation(seed in 1u8..=200u8) { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + mint_tokens(&test_env.env, &token, &creator, 10_000_000); + + client.pause(&test_env.admin); + + let id = group_id(&test_env.env, seed); + let name = String::from_str(&test_env.env, "Paused Fuzz"); + + let result = client.try_create(&id, &name, &creator, &1u32, &token); + prop_assert!(result.is_err()); + prop_assert!(client.get_paused_status()); + } +} + +#[test] +fn fuzz_reduce_usage_never_exceeds_paid_total() { + let test_env = setup_test_env(); + let client = AutoShareContractClient::new(&test_env.env, &test_env.autoshare_contract); + let creator = test_env.users.get(0).unwrap().clone(); + let mut members = Vec::new(&test_env.env); + members.push_back(GroupMember { + address: Address::generate(&test_env.env), + percentage: 100, + }); + let token = test_env.mock_tokens.get(0).unwrap().clone(); + let usages = 5u32; + + let id = create_test_group( + &test_env.env, + &test_env.autoshare_contract, + &creator, + &members, + usages, + &token, + ); + + for _ in 0..usages { + client.reduce_usage(&id); + } + + assert_eq!(client.get_remaining_usages(&id), 0); + + let overuse = client.try_reduce_usage(&id); + assert!(overuse.is_err()); +} diff --git a/dashboard/package.json b/dashboard/package.json index 83122ee..0443ea8 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,6 +9,7 @@ "dev": "node ./node_modules/vite/bin/vite.js", "lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0", "test": "node ./node_modules/jest/bin/jest.js", + "test:wallet": "node ./node_modules/jest/bin/jest.js src/__tests__/wallet-integration.test.tsx", "benchmark": "node ./node_modules/jest/bin/jest.js src/benchmark" }, "dependencies": { diff --git a/dashboard/reports/wallet-integration.json b/dashboard/reports/wallet-integration.json new file mode 100644 index 0000000..5b7362a --- /dev/null +++ b/dashboard/reports/wallet-integration.json @@ -0,0 +1,20 @@ +{ + "generatedAt": "2026-06-24T19:47:36.590Z", + "total": 3, + "passed": 3, + "failed": 0, + "wallets": [ + { + "wallet": "freighter", + "passed": true + }, + { + "wallet": "albedo", + "passed": true + }, + { + "wallet": "xbull", + "passed": true + } + ] +} \ No newline at end of file diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx new file mode 100644 index 0000000..309ae25 --- /dev/null +++ b/dashboard/src/__tests__/wallet-integration.test.tsx @@ -0,0 +1,193 @@ +/** + * End-to-end wallet integration tests across supported wallet providers. + * Uses the Stellar Wallets Kit mock to simulate Freighter, Albedo, and xBull flows. + */ + +import '@testing-library/jest-dom'; +import { jest } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; + +const WALLET_ID_KEY = 'notify-chain:wallet-id'; +const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; +const REPORT_PATH = path.join(process.cwd(), 'reports', 'wallet-integration.json'); + +type KitMock = typeof import('../test/stellarWalletsKitMock'); +type WalletService = typeof import('../services/wallet'); +type WalletStoreModule = typeof import('../store/walletStore'); + +const SUPPORTED_WALLETS = [ + { id: 'freighter', label: 'Freighter', address: 'GFREIGHTER1234567890ABCDEFGHIJK' }, + { id: 'albedo', label: 'Albedo', address: 'GALBEDO1234567890ABCDEFGHIJKLM' }, + { id: 'xbull', label: 'xBull', address: 'GXBULL1234567890ABCDEFGHIJKLMNO' }, +] as const; + +async function load(): Promise<{ + kit: KitMock; + wallet: WalletService; + store: WalletStoreModule; +}> { + jest.resetModules(); + const kit = (await import('@creit.tech/stellar-wallets-kit')) as unknown as KitMock; + const store = await import('../store/walletStore'); + const wallet = await import('../services/wallet'); + return { kit, wallet, store }; +} + +function writeReport(results: { wallet: string; passed: boolean }[]) { + const reportsDir = path.dirname(REPORT_PATH); + if (!fs.existsSync(reportsDir)) fs.mkdirSync(reportsDir, { recursive: true }); + fs.writeFileSync( + REPORT_PATH, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + total: results.length, + passed: results.filter((r) => r.passed).length, + failed: results.filter((r) => !r.passed).length, + wallets: results, + }, + null, + 2 + ) + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('Wallet connection flows', () => { + it('connects via auth modal and stores the selected wallet address', async () => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + + await wallet.connectWallet(); + + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[0].address); + expect(localStorage.getItem(WALLET_ID_KEY)).toBe('freighter'); + expect(store.useWalletStore.getState().isConnecting).toBe(false); + expect(store.useWalletStore.getState().error).toBeNull(); + }); + + it('surfaces auth modal failures as recoverable errors', async () => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + throw new Error('User rejected the connection request'); + }; + + await wallet.connectWallet(); + + expect(store.useWalletStore.getState().address).toBeNull(); + expect(store.useWalletStore.getState().error).toBe('User rejected the connection request'); + expect(store.useWalletStore.getState().isConnecting).toBe(false); + }); +}); + +describe('Wallet switching', () => { + it.each(SUPPORTED_WALLETS)('switches to $label and updates persisted wallet id', async ({ id, address }) => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id }); + kit.__emit('STATE_UPDATED', { address }); + }; + + await wallet.connectWallet(); + + expect(localStorage.getItem(WALLET_ID_KEY)).toBe(id); + expect(store.useWalletStore.getState().address).toBe(address); + }); + + it('switches wallets without leaving a stale session', async () => { + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + await wallet.connectWallet(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'albedo' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[1].address }); + }; + await wallet.connectWallet(); + + expect(localStorage.getItem(WALLET_ID_KEY)).toBe('albedo'); + expect(store.useWalletStore.getState().address).toBe(SUPPORTED_WALLETS[1].address); + }); +}); + +describe('Notification workflow with wallet connected', () => { + it('loads events while wallet session is active', async () => { + const fetchMock = jest + .fn<() => Promise>() + .mockResolvedValue({ + ok: true, + json: async () => ({ + events: [ + { + id: 'evt-wallet-1', + contractAddress: 'CTEST', + topic: 'task_created', + ledger: 100, + timestamp: '2026-06-24T12:00:00Z', + }, + ], + }), + } as unknown as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + const { fetchEvents } = await import('../services/eventsApi'); + const { wallet, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: 'freighter' }); + kit.__emit('STATE_UPDATED', { address: SUPPORTED_WALLETS[0].address }); + }; + await wallet.connectWallet(); + + const events = await fetchEvents('http://localhost:8787/api/events'); + expect(events).toHaveLength(1); + expect(events[0].id).toBe('evt-wallet-1'); + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8787/api/events'); + }); +}); + +describe('Wallet integration report', () => { + it('records pass status for all supported wallet providers', async () => { + const results: { wallet: string; passed: boolean }[] = []; + + for (const provider of SUPPORTED_WALLETS) { + localStorage.clear(); + const { wallet, store, kit } = await load(); + + kit.__control.authModalImpl = async () => { + kit.__emit('WALLET_SELECTED', { id: provider.id }); + kit.__emit('STATE_UPDATED', { address: provider.address }); + }; + + await wallet.connectWallet(); + const passed = + store.useWalletStore.getState().address === provider.address && + localStorage.getItem(WALLET_ID_KEY) === provider.id; + + results.push({ wallet: provider.id, passed }); + } + + writeReport(results); + expect(results.every((r) => r.passed)).toBe(true); + expect(fs.existsSync(REPORT_PATH)).toBe(true); + }); +}); diff --git a/listener/src/__tests__/scheduled-notification-lifecycle.e2e.test.ts b/listener/src/__tests__/scheduled-notification-lifecycle.e2e.test.ts new file mode 100644 index 0000000..e4074a1 --- /dev/null +++ b/listener/src/__tests__/scheduled-notification-lifecycle.e2e.test.ts @@ -0,0 +1,182 @@ +/** + * End-to-end tests for the scheduled notification lifecycle: + * creation → delayed execution → delivery → cleanup. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { Database } from '../database/database'; +import { ScheduledNotificationRepository } from '../services/scheduled-notification-repository'; +import { NotificationScheduler } from '../services/notification-scheduler'; +import { NotificationAPI } from '../services/notification-api'; +import { DiscordNotificationService } from '../services/discord-notification'; +import { CleanupService } from '../services/cleanup-service'; +import { EventRegistry } from '../store/event-registry'; +import { NotificationFixtureBuilder } from '../test-utils/notification-fixture-builder'; +import { NotificationStatus, NotificationType } from '../types/scheduled-notification'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +describe('Scheduled notification lifecycle (e2e)', () => { + const testDbPath = './data/test-scheduled-lifecycle.db'; + let db: Database; + let repository: ScheduledNotificationRepository; + let api: NotificationAPI; + let scheduler: NotificationScheduler; + let sendEventMock: jest.Mock; + + const schedulerConfig = { + enabled: true, + pollIntervalMs: 100, + lockTimeoutMs: 30000, + batchSize: 10, + timingBufferMs: 0, + processorId: 'e2e-processor', + }; + + beforeAll(async () => { + const dbDir = path.dirname(testDbPath); + if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }); + if (fs.existsSync(testDbPath)) fs.unlinkSync(testDbPath); + + db = new Database(testDbPath); + await db.initialize(); + repository = new ScheduledNotificationRepository(db); + api = new NotificationAPI(repository); + }); + + afterAll(async () => { + await scheduler?.stop(); + await db.close(); + if (fs.existsSync(testDbPath)) fs.unlinkSync(testDbPath); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-24T12:00:00.000Z')); + + await db.run('DELETE FROM notification_execution_log'); + await db.run('DELETE FROM scheduled_notifications'); + + sendEventMock = jest.fn().mockResolvedValue(true); + const discordService = { + sendEventNotification: sendEventMock, + } as unknown as DiscordNotificationService; + + scheduler = new NotificationScheduler(repository, schedulerConfig, discordService); + }); + + afterEach(async () => { + await scheduler.stop(); + jest.useRealTimers(); + }); + + function schedulerPayload() { + const event = NotificationFixtureBuilder.aStellarEvent().withId('sched-e2e-1').build(); + const contractConfig = NotificationFixtureBuilder.aContractConfig().build(); + return { event, contractConfig, message: 'Scheduled bounty alert' }; + } + + async function createDueNotification() { + return repository.create( + NotificationFixtureBuilder.aScheduledNotificationInput() + .forImmediateExecution() + .withPayload(schedulerPayload()) + .withTargetRecipient(NotificationFixtureBuilder.constants.webhookUrl) + .build() + ); + } + + it('schedules a notification and delivers it after the poll interval', async () => { + const executeAt = new Date('2026-06-24T12:00:02.000Z'); + + const id = await api.scheduleNotification({ + payload: schedulerPayload(), + notificationType: NotificationType.DISCORD, + targetRecipient: NotificationFixtureBuilder.constants.webhookUrl, + executeAt, + maxRetries: 2, + }); + + let notification = await repository.getById(id); + expect(notification!.status).toBe(NotificationStatus.PENDING); + + await scheduler.start(); + await jest.advanceTimersByTimeAsync(250); + + notification = await repository.getById(id); + expect(notification!.status).toBe(NotificationStatus.COMPLETED); + expect(sendEventMock).toHaveBeenCalledTimes(1); + + const logs = await db.all( + 'SELECT * FROM notification_execution_log WHERE scheduled_notification_id = ?', + [id] + ); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('SUCCESS'); + }); + + it('does not deliver notifications before their executeAt time', async () => { + const executeAt = new Date('2026-06-24T12:05:00.000Z'); + + const id = await api.scheduleNotification({ + payload: schedulerPayload(), + notificationType: NotificationType.DISCORD, + targetRecipient: NotificationFixtureBuilder.constants.webhookUrl, + executeAt, + }); + + await scheduler.start(); + await jest.advanceTimersByTimeAsync(250); + + const notification = await repository.getById(id); + expect(notification!.status).toBe(NotificationStatus.PENDING); + expect(sendEventMock).not.toHaveBeenCalled(); + }); + + it('delivers when executeAt elapses after scheduling', async () => { + const executeAt = new Date('2026-06-24T12:05:00.000Z'); + + const id = await api.scheduleNotification({ + payload: schedulerPayload(), + notificationType: NotificationType.DISCORD, + targetRecipient: NotificationFixtureBuilder.constants.webhookUrl, + executeAt, + }); + + await scheduler.start(); + await jest.advanceTimersByTimeAsync(200); + expect((await repository.getById(id))!.status).toBe(NotificationStatus.PENDING); + + jest.setSystemTime(new Date('2026-06-24T12:05:01.000Z')); + await jest.advanceTimersByTimeAsync(500); + + expect((await repository.getById(id))!.status).toBe(NotificationStatus.COMPLETED); + expect(sendEventMock).toHaveBeenCalledTimes(1); + }); + + it('runs cleanup after completed notifications age past retention', async () => { + const id = await createDueNotification(); + + await scheduler.start(); + await jest.advanceTimersByTimeAsync(150); + expect((await repository.getById(id))!.status).toBe(NotificationStatus.COMPLETED); + + const registry = new EventRegistry(); + const cleanup = new CleanupService(db, registry, { + intervalMs: 60_000, + notificationRetentionMs: 1, + rateLimitEventRetentionMs: 1, + }); + + jest.setSystemTime(new Date('2026-06-24T13:00:00.000Z')); + const result = await cleanup.runDbCleanup(); + + expect(result.notifications).toBeGreaterThanOrEqual(1); + expect(await repository.getById(id)).toBeNull(); + }); +}); diff --git a/listener/src/api/events-server.test.ts b/listener/src/api/events-server.test.ts index ce79509..9f270d6 100644 --- a/listener/src/api/events-server.test.ts +++ b/listener/src/api/events-server.test.ts @@ -441,6 +441,41 @@ describe('GET /api/analytics', () => { }); }); +describe('POST /api/notifications/validate-batch', () => { + let server: http.Server; + + beforeEach((done) => { + jest.clearAllMocks(); + server = createEventsServer({ port: 0, stellarRpcUrl: 'http://localhost' }); + server.listen(0, '127.0.0.1', done); + }); + + afterEach((done) => { + server.close(done); + }); + + it('accepts a valid notification batch', async () => { + const res = await request(server, 'POST', '/api/notifications/validate-batch', [ + { id: 'n1', recipient: 'user_a', channel: 'discord', message: 'Hello' }, + { id: 'n2', recipient: 'user_b', channel: 'webhook', message: 'Hi' }, + ]); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ valid: true, processedCount: 2, errors: [] }); + }); + + it('rejects batches with duplicate recipients and missing fields', async () => { + const res = await request(server, 'POST', '/api/notifications/validate-batch', [ + { id: 'n1', recipient: 'user_a', channel: 'discord', message: 'Hello' }, + { id: 'n2', recipient: 'user_a', channel: 'webhook', message: 'Duplicate' }, + { id: '', recipient: '', channel: 'email', message: '' }, + ]); + + expect(res.status).toBe(400); + const body = res.body as { valid: boolean; errors: Array<{ code: string }> }; + expect(body.valid).toBe(false); + expect(body.errors.some((e) => e.code === 'DUPLICATE_RECIPIENT')).toBe(true); + expect(body.errors.some((e) => e.code === 'MISSING_FIELD' || e.code === 'EMPTY_FIELD')).toBe(true); describe('GET /api/search/suggestions API', () => { let server: http.Server; let db: Database; diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 4591a10..331f8ea 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -35,6 +35,7 @@ import { serializeTemplate, } from './template-api'; import { CreateNotificationTemplateInput } from '../types/notification-template'; +import { BatchValidationService } from '../services/batch-validation-service'; import { handleArchiveRequest } from './archive-api'; import { ArchiveStore } from '../services/archive-store'; import { ArchiveService } from '../services/archive-service'; @@ -567,6 +568,36 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // POST /api/notifications/validate-batch + if (req.method === 'POST' && url.pathname === '/api/notifications/validate-batch') { + let body = ''; + req.on('data', (chunk) => { body += chunk.toString(); }); + req.on('end', () => { + try { + const data = JSON.parse(body || 'null'); + const batch = Array.isArray(data) ? data : data?.notifications; + const validator = new BatchValidationService(); + const result = validator.validate(batch); + + if (!result.valid) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + logger.warn('Batch validation rejected', { requestId, correlationId, errorCount: result.errors.length }); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + logger.info('Batch validation passed', { requestId, correlationId, processedCount: result.processedCount }); + } catch (error) { + logger.error('Failed to validate notification batch', { error, requestId, correlationId }); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ valid: false, processedCount: 0, errors: [{ index: -1, code: 'PARSE_ERROR', message: 'Request body must be valid JSON.' }] })); + } + }); + return; + } + // POST /api/schedule if (req.method === 'POST' && url.pathname === '/api/schedule') { if (!options.notificationAPI) { diff --git a/listener/src/services/batch-validation-service.ts b/listener/src/services/batch-validation-service.ts new file mode 100644 index 0000000..b50d8ca --- /dev/null +++ b/listener/src/services/batch-validation-service.ts @@ -0,0 +1,54 @@ +import { + BatchValidator, + BatchValidationResult, + NotificationPayload, + VALID_CHANNELS, +} from '../utils/batch-validator'; + +export interface BatchValidationError { + index: number; + field?: string; + code: string; + message: string; +} + +export interface BatchValidationResponse { + valid: boolean; + processedCount: number; + errors: BatchValidationError[]; +} + +/** + * Service layer for notification batch validation. + * Rejects malformed batches early with actionable, structured errors. + */ +export class BatchValidationService { + validate(batch: unknown): BatchValidationResponse { + const result = BatchValidator.validateBatch(batch); + return this.toResponse(result); + } + + /** + * Returns null when the batch is valid; otherwise returns the validation response. + */ + rejectIfInvalid(batch: unknown): BatchValidationResponse | null { + const response = this.validate(batch); + return response.valid ? null : response; + } + + private toResponse(result: BatchValidationResult): BatchValidationResponse { + return { + valid: result.isValid, + processedCount: result.processedCount, + errors: result.errors.map((err) => ({ + index: err.index, + field: err.field, + code: err.code, + message: err.message, + })), + }; + } +} + +export type { NotificationPayload, BatchValidationResult }; +export { VALID_CHANNELS }; diff --git a/listener/src/services/notification-scheduler.ts b/listener/src/services/notification-scheduler.ts index 5afe448..f0247dc 100644 --- a/listener/src/services/notification-scheduler.ts +++ b/listener/src/services/notification-scheduler.ts @@ -4,6 +4,8 @@ import { generateRequestId } from '../utils/request-id'; import { ScheduledNotificationRepository } from './scheduled-notification-repository'; import { SchedulerConfig, ScheduledNotification } from '../types/scheduled-notification'; import { DiscordNotificationService } from './discord-notification'; +import { BatchValidationService } from './batch-validation-service'; +import { NotificationChannel } from '../utils/batch-validator'; /** * Background scheduler that processes scheduled notifications @@ -20,16 +22,19 @@ export class NotificationScheduler { private timer: NodeJS.Timeout | null = null; private isRunning: boolean = false; private processorId: string; + private batchValidator: BatchValidationService; constructor( repository: ScheduledNotificationRepository, config: SchedulerConfig, - discordService?: DiscordNotificationService | null + discordService?: DiscordNotificationService | null, + batchValidator?: BatchValidationService ) { this.repository = repository; this.config = config; this.discordService = discordService ?? null; this.processorId = config.processorId || uuidv4(); + this.batchValidator = batchValidator ?? new BatchValidationService(); } /** @@ -118,6 +123,28 @@ export class NotificationScheduler { return; } + const batchRejection = this.batchValidator.rejectIfInvalid( + this.toValidationBatch(notifications) + ); + + if (batchRejection) { + logger.error('Scheduled notification batch rejected by validation', { + requestId, + processorId: this.processorId, + errors: batchRejection.errors, + }); + + for (const notification of notifications) { + await this.repository.markAsFailedOrRetry( + notification.id!, + new Error(`Batch validation failed: ${batchRejection.errors.map((e) => e.message).join('; ')}`), + notification.retryCount, + notification.maxRetries + ); + } + return; + } + logger.info('Processing batch of scheduled notifications', { requestId, count: notifications.length, @@ -280,4 +307,28 @@ export class NotificationScheduler { async getStats() { return await this.repository.getStats(); } + + private toValidationBatch(notifications: ScheduledNotification[]) { + return notifications.map((notification) => ({ + id: String(notification.id), + recipient: notification.targetRecipient, + channel: notification.notificationType as NotificationChannel, + message: this.extractValidationMessage(notification.payload), + })); + } + + private extractValidationMessage(payloadJson: string): string { + try { + const payload = JSON.parse(payloadJson); + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message; + } + if (typeof payload.content === 'string' && payload.content.trim()) { + return payload.content; + } + return JSON.stringify(payload).slice(0, 200); + } catch { + return payloadJson.slice(0, 200) || 'scheduled-notification'; + } + } } diff --git a/listener/src/utils/batch-validator.test.ts b/listener/src/utils/batch-validator.test.ts new file mode 100644 index 0000000..886cb68 --- /dev/null +++ b/listener/src/utils/batch-validator.test.ts @@ -0,0 +1,79 @@ +import { BatchValidator } from './batch-validator'; +import { BatchValidationService } from '../services/batch-validation-service'; + +describe('BatchValidator', () => { + const validItem = { + id: 'evt_001', + recipient: 'channel_alpha', + channel: 'discord' as const, + message: 'Hello world', + }; + + it('accepts a valid batch', () => { + const result = BatchValidator.validateBatch([validItem, { ...validItem, id: 'evt_002', recipient: 'channel_beta' }]); + expect(result.isValid).toBe(true); + expect(result.processedCount).toBe(2); + expect(result.errors).toHaveLength(0); + }); + + it('rejects non-array input', () => { + const result = BatchValidator.validateBatch({ id: 'x' }); + expect(result.isValid).toBe(false); + expect(result.errors[0].code).toBe('INVALID_STRUCTURE'); + }); + + it('rejects empty batches', () => { + const result = BatchValidator.validateBatch([]); + expect(result.isValid).toBe(false); + expect(result.errors[0].code).toBe('EMPTY_BATCH'); + }); + + it('detects missing required fields', () => { + const result = BatchValidator.validateBatch([{ id: 'evt_001' }]); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.code === 'MISSING_FIELD' && e.field === 'recipient')).toBe(true); + expect(result.errors.some((e) => e.code === 'MISSING_FIELD' && e.field === 'channel')).toBe(true); + expect(result.errors.some((e) => e.code === 'MISSING_FIELD' && e.field === 'message')).toBe(true); + }); + + it('detects duplicate recipients (case-insensitive)', () => { + const result = BatchValidator.validateBatch([ + validItem, + { ...validItem, id: 'evt_002', recipient: 'Channel_Alpha' }, + ]); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.code === 'DUPLICATE_RECIPIENT')).toBe(true); + }); + + it('rejects unsupported channels', () => { + const result = BatchValidator.validateBatch([{ ...validItem, channel: 'telegram' }]); + expect(result.isValid).toBe(false); + expect(result.errors[0].code).toBe('INVALID_CHANNEL'); + }); + + it('rejects empty string fields', () => { + const result = BatchValidator.validateBatch([{ ...validItem, message: ' ' }]); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.code === 'EMPTY_FIELD' && e.field === 'message')).toBe(true); + }); +}); + +describe('BatchValidationService', () => { + const service = new BatchValidationService(); + + it('returns null for valid batches so processing can continue', () => { + const rejection = service.rejectIfInvalid([ + { id: 'a', recipient: 'r1', channel: 'discord', message: 'm' }, + ]); + expect(rejection).toBeNull(); + }); + + it('returns structured errors for invalid batches', () => { + const response = service.validate([]); + expect(response.valid).toBe(false); + expect(response.errors[0]).toMatchObject({ + code: 'EMPTY_BATCH', + message: expect.stringContaining('at least one'), + }); + }); +}); diff --git a/listener/src/utils/batch-validator.ts b/listener/src/utils/batch-validator.ts index d61e1c4..705bee2 100644 --- a/listener/src/utils/batch-validator.ts +++ b/listener/src/utils/batch-validator.ts @@ -1,44 +1,136 @@ import * as fs from 'fs'; import * as path from 'path'; +export const VALID_CHANNELS = ['discord', 'webhook', 'email', 'sms'] as const; +export type NotificationChannel = (typeof VALID_CHANNELS)[number]; + export interface NotificationPayload { id: string; recipient: string; - channel: 'discord' | 'webhook' | 'email'; + channel: NotificationChannel; + message: string; +} + +export interface BatchValidationErrorDetail { + index: number; + field?: string; + code: string; message: string; } export interface BatchValidationResult { isValid: boolean; processedCount: number; - errors: string[]; + errors: BatchValidationErrorDetail[]; } export class BatchValidator { - public static validateBatch(batch: any[]): BatchValidationResult { + public static validateBatch(batch: unknown): BatchValidationResult { const result: BatchValidationResult = { isValid: true, processedCount: 0, errors: [] }; const seenRecipients = new Set(); - if (!Array.isArray(batch) || batch.length === 0) { - result.errors.push("Invalid batch structure: Batch must be a non-empty array."); + if (!Array.isArray(batch)) { + result.errors.push({ + index: -1, + code: 'INVALID_STRUCTURE', + message: 'Batch must be a JSON array of notification payloads.', + }); result.isValid = false; return result; } - batch.forEach((payload, index) => { - const locationId = `Item at index [${index}]`; + if (batch.length === 0) { + result.errors.push({ + index: -1, + code: 'EMPTY_BATCH', + message: 'Batch must contain at least one notification.', + }); + result.isValid = false; + return result; + } - if (!payload.id || !payload.recipient || !payload.channel || !payload.message) { - result.errors.push(`${locationId}: Missing required fields. (Must contain 'id', 'recipient', 'channel', 'message')`); + batch.forEach((payload, index) => { + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + result.errors.push({ + index, + code: 'INVALID_ITEM', + message: `Item at index [${index}] must be an object.`, + }); result.isValid = false; return; } - if (seenRecipients.has(payload.recipient)) { - result.errors.push(`${locationId}: Duplicate recipient detected ('${payload.recipient}'). Batch throttling enforced.`); + const item = payload as Record; + const requiredFields: Array = ['id', 'recipient', 'channel', 'message']; + + for (const field of requiredFields) { + const value = item[field]; + if (value === undefined || value === null || value === '') { + result.errors.push({ + index, + field, + code: 'MISSING_FIELD', + message: `Item at index [${index}]: Missing required field '${field}'.`, + }); + result.isValid = false; + } + } + + if (typeof item.id === 'string' && item.id.trim() === '') { + result.errors.push({ + index, + field: 'id', + code: 'EMPTY_FIELD', + message: `Item at index [${index}]: Field 'id' must not be empty.`, + }); + result.isValid = false; + } + + if (typeof item.recipient === 'string' && item.recipient.trim() === '') { + result.errors.push({ + index, + field: 'recipient', + code: 'EMPTY_FIELD', + message: `Item at index [${index}]: Field 'recipient' must not be empty.`, + }); result.isValid = false; - } else { - seenRecipients.add(payload.recipient); + } + + if (typeof item.message === 'string' && item.message.trim() === '') { + result.errors.push({ + index, + field: 'message', + code: 'EMPTY_FIELD', + message: `Item at index [${index}]: Field 'message' must not be empty.`, + }); + result.isValid = false; + } + + if (item.channel !== undefined) { + if (!VALID_CHANNELS.includes(item.channel as NotificationChannel)) { + result.errors.push({ + index, + field: 'channel', + code: 'INVALID_CHANNEL', + message: `Item at index [${index}]: Channel '${item.channel}' is not supported. Allowed: ${VALID_CHANNELS.join(', ')}.`, + }); + result.isValid = false; + } + } + + if (typeof item.recipient === 'string' && item.recipient.trim() !== '') { + const normalized = item.recipient.trim().toLowerCase(); + if (seenRecipients.has(normalized)) { + result.errors.push({ + index, + field: 'recipient', + code: 'DUPLICATE_RECIPIENT', + message: `Item at index [${index}]: Duplicate recipient '${item.recipient}'. Each recipient may appear only once per batch.`, + }); + result.isValid = false; + } else { + seenRecipients.add(normalized); + } } }); @@ -52,12 +144,12 @@ export class BatchValidator { function runTerminalSimulation() { const sampleMockBatch = [ - { id: "evt_001", recipient: "discord_channel_alpha", channel: "discord", message: "TaskCreated: Bounty #42 active." }, - { id: "evt_002", recipient: "discord_channel_alpha", channel: "discord", message: "WorkSubmitted: Task completed." }, - { id: "evt_003", recipient: "", channel: "webhook", message: "Missing recipient details" } + { id: 'evt_001', recipient: 'discord_channel_alpha', channel: 'discord', message: 'TaskCreated: Bounty #42 active.' }, + { id: 'evt_002', recipient: 'discord_channel_alpha', channel: 'discord', message: 'WorkSubmitted: Task completed.' }, + { id: 'evt_003', recipient: '', channel: 'webhook', message: 'Missing recipient details' }, ]; - console.log("šŸš€ Running NotifyChain Batch Validation Check..."); + console.log('šŸš€ Running NotifyChain Batch Validation Check...'); const validationReport = BatchValidator.validateBatch(sampleMockBatch); const reportsDir = path.join(__dirname, '../../reports'); @@ -74,7 +166,7 @@ function runTerminalSimulation() { console.log(`\nšŸ“Š Execution Results Logged:`); console.log(` Status: ${validationReport.isValid ? '🟩 PASSED' : '🟄 REJECTED'}`); console.log(` Errors Found: ${validationReport.errors.length}`); - validationReport.errors.forEach(err => console.log(` āš ļø ${err}`)); + validationReport.errors.forEach((err) => console.log(` āš ļø ${err.message}`)); console.log(`\nšŸ’¾ Saved audit report to: listener/reports/last-validation-run.json`); } diff --git a/scripts/run-fuzz-coverage.sh b/scripts/run-fuzz-coverage.sh new file mode 100755 index 0000000..b6658f8 --- /dev/null +++ b/scripts/run-fuzz-coverage.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Generate a coverage summary for contract fuzz tests. +set -euo pipefail + +cd "$(dirname "$0")/../contract" + +echo "Running fuzz tests..." +FUZZ_OUTPUT=$(cargo test fuzz_ -- --nocapture 2>&1) +echo "$FUZZ_OUTPUT" + +PASSED=$(echo "$FUZZ_OUTPUT" | grep -c "test result: ok" || true) +REPORT_DIR="../contract/reports" +mkdir -p "$REPORT_DIR" + +cat > "$REPORT_DIR/fuzz-coverage.json" <