From d36a87f9946129681eade5ebc5cd3fcf439558e6 Mon Sep 17 00:00:00 2001 From: amberly-d <294444926+amberly-d@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:07:08 +0100 Subject: [PATCH 1/3] Harden release profile, add dependency audit, coverage gates and wallet tests SC-37: release profile - Add lto, codegen-units = 1, strip = "symbols", debug = 0 and panic = "abort". - Keep overflow-checks = true. It costs size and gas, but these contracts move asset value and share counts, and a silent wrap is worse than a trapped transaction. Turning the traps into typed errors is SC-43. - Add a release-with-logs profile inheriting release with debug assertions on, for testnet debugging. - Measured WASM shrinks 43.4% overall, from 440,636 to 249,183 bytes: assetsup -36.6%, contrib -39.8%, multisig_wallet -41.8%, asset_maintenance -64.4%, multisig_transfer -45.9%. Per-contract figures are recorded in contracts/BUILD.md. All tests pass unchanged under panic=abort. SC-38: dependency audit - Add a security job running cargo audit and cargo deny, with the advisory database and tooling cached, plus a weekly schedule so advisories published after a merge are still caught. - Add deny.toml covering advisories, a permissive licence allowlist, duplicate-version detection and a crates.io-only source policy. It passes as committed. - Mark all five crates publish = false. They deploy as WASM and are never published, and it lets cargo-deny stop reporting them as unlicensed without inventing a licence the repository does not state. - Two advisories are ignored with reasoning and a review date, both unmaintained compile-time proc macros reached through soroban-sdk. Yanked crates warn rather than deny: soroban-sdk 22 pins yanked spin and keccak inside its own tree, unresolvable from this workspace, and a permanently red job is one everyone learns to ignore. SC-35 should clear both. SC-39: coverage - Add a coverage job running cargo-llvm-cov and uploading the lcov report. - Add scripts/check-coverage.sh enforcing a workspace floor and, more importantly, a per-crate floor. A workspace average hides a crate with no tests behind well-tested siblings, which is how multisig_transfer reached 755 lines at zero coverage unnoticed. - Floors are set just under the measured level so the build stays green and can be ratcheted up: assetsup 70, contrib 35, multisig-wallet 90, asset-maintenance 70, workspace 65. - Any crate at zero coverage fails unless explicitly listed with its tracking issue. multisig_transfer is the only entry, pending SC-32; removing it is how the gate tightens. Verified the gate fails when the exemption is removed. SC-40: multisig-wallet coverage - 4 tests to 60, covering every public entrypoint. - Threshold and signer-count invariants at their boundaries: zero threshold, threshold above owner count, threshold equal to owner count, fewer than two owners, and removal that would drop owners below the threshold. - Proposal lifecycle including rejection of re-execution and of execution below the threshold. - Duplicate approvals, approval by a non-owner, and that a removed signer can no longer confirm. - Negative auth tests without mock_all_auths, so a missing require_auth fails rather than passing silently. One behavioural finding, pinned as a test rather than changed here: confirm_transaction sets status = Expired and writes it before returning Err(TransactionExpired), but Soroban rolls back storage writes on a failed invocation, so the write never lands and the transaction stays Pending. Nothing can currently move a transaction into Expired, so a consumer filtering on that status will never match. Giving it a real transition needs a separate entrypoint, which is beyond a test-coverage change. Closes #1201 Closes #1202 Closes #1203 Closes #1204 --- .github/workflows/CI.yaml | 86 ++ contracts/.gitignore | 2 + contracts/BUILD.md | 142 +++ contracts/Cargo.toml | 21 +- contracts/asset-maintenance/Cargo.toml | 2 + contracts/assetsup/Cargo.toml | 2 + contracts/contrib/Cargo.toml | 2 + contracts/deny.toml | 122 +++ contracts/multisig-transfer/Cargo.toml | 2 + contracts/multisig-wallet/Cargo.toml | 2 + contracts/multisig-wallet/src/lib.rs | 2 + .../multisig-wallet/src/tests_coverage.rs | 965 ++++++++++++++++++ contracts/scripts/check-coverage.sh | 151 +++ 13 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 contracts/BUILD.md create mode 100644 contracts/deny.toml create mode 100644 contracts/multisig-wallet/src/tests_coverage.rs create mode 100755 contracts/scripts/check-coverage.sh diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 14a57ce92..3182a8371 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: branches: [main] + # Advisories are published continuously, so a lockfile that was clean at + # merge time can become vulnerable without anything in the repo changing. + # This re-runs the audit weekly against the committed Cargo.lock. + schedule: + - cron: "0 6 * * 1" env: CARGO_TERM_COLOR: always @@ -160,6 +165,87 @@ jobs: if-no-files-found: error retention-days: 14 + security: + name: Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + # The advisory database is a git clone of ~200MB of history; caching it + # keyed on the week keeps the job to a few seconds on most runs while + # still refreshing regularly. + - name: Cache advisory database + uses: actions/cache@v4 + with: + path: ~/.cargo/advisory-db + key: advisory-db-${{ github.run_id }} + restore-keys: advisory-db- + + - name: Cache audit tooling + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/cargo-audit + ~/.cargo/bin/cargo-deny + key: ${{ runner.os }}-audit-tools-v1 + + - name: Install cargo-audit and cargo-deny + run: | + command -v cargo-audit >/dev/null || cargo install --locked cargo-audit + command -v cargo-deny >/dev/null || cargo install --locked cargo-deny + + - name: cargo audit + run: cargo audit --file Cargo.lock --deny warnings + + # Covers advisories, the license allowlist, duplicate versions, and + # source registries. See contracts/deny.toml for the triage process. + - name: cargo deny + run: cargo deny --all-features check + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: llvm-tools-preview + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: ${{ runner.os }}-cargo-cov-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-cov- + ${{ runner.os }}-cargo- + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate coverage + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + + # Enforces the per-crate floor as well as the workspace threshold. A + # workspace average hides a crate with no tests at all, which is exactly + # how multisig_transfer reached 755 lines with zero coverage. + - name: Check coverage thresholds + run: ./scripts/check-coverage.sh + + - name: Upload lcov report + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: contracts/lcov.info + retention-days: 14 + # ───────────────────────────────────────────── # BACKEND — NestJS # ───────────────────────────────────────────── diff --git a/contracts/.gitignore b/contracts/.gitignore index 89a6be20b..b89442efc 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -7,3 +7,5 @@ test_snapshots/ # deployments/testnet.example.json documents the shape. deployments/* !deployments/*.example.json +# Coverage reports produced by cargo-llvm-cov. +lcov.info diff --git a/contracts/BUILD.md b/contracts/BUILD.md new file mode 100644 index 000000000..f16bcd91c --- /dev/null +++ b/contracts/BUILD.md @@ -0,0 +1,142 @@ +# Build profiles, WASM size, and quality gates + +## Release profile + +Soroban charges for resource usage and enforces a size limit on deployed +contracts, so the release profile in [`Cargo.toml`](Cargo.toml) is tuned for the +smallest artifact that is still safe to run. + +| Setting | Value | Why | +|---|---|---| +| `opt-level` | `"z"` | Optimize for size rather than speed. | +| `lto` | `true` | Cross-crate inlining and dead-code elimination. | +| `codegen-units` | `1` | Lets LLVM see the whole crate at once. | +| `strip` | `"symbols"` | Symbol names are dead weight on-chain. | +| `debug` | `0` | No debug info in a deployed artifact. | +| `panic` | `"abort"` | Unwinding tables are unusable in Soroban. | +| `overflow-checks` | `true` | **Kept deliberately** — see below. | + +### Why `overflow-checks` stays on + +It costs size and gas. These contracts move asset value, share counts, and +dividend splits, where a silent wraparound is far worse than a trapped +transaction. The trap is not the end state we want either — converting these +into typed errors is tracked in [SC-43] — but until then, trapping beats +wrapping. + +### `release-with-logs` + +A second profile inherits `release` and re-enables `debug-assertions`, for +debugging on testnet where a precondition failure should be loud: + +```sh +cargo build --workspace --target wasm32-unknown-unknown --profile release-with-logs +``` + +Never deploy this to mainnet. Debug assertions change failure behaviour and add +size. + +## Measured WASM size + +Built with `cargo build --workspace --target wasm32-unknown-unknown --release`, +before any `stellar contract optimize` pass. + +| Contract | Before | After | Change | +|---|---:|---:|---:| +| `assetsup` | 162,775 | 103,140 | **−36.6%** | +| `contrib` | 79,282 | 47,758 | **−39.8%** | +| `multisig_wallet` | 74,214 | 43,157 | **−41.8%** | +| `asset_maintenance` | 65,756 | 23,421 | **−64.4%** | +| `multisig_transfer` | 58,609 | 31,707 | **−45.9%** | +| **Total** | **440,636** | **249,183** | **−43.4%** | + +`asset-maintenance` gains the most because it was carrying the most +monomorphized code that `lto` could collapse. `assetsup` remains the largest by +a wide margin and is the crate to watch against the deployment limit — it is +the subject of the split assessment in [SC-46]. + +These are the baselines. Tracking size per PR and enforcing a budget is +[SC-52]. + +All 298 tests pass unchanged under `panic = "abort"`. + +## Quality gates in CI + +| Job | Command | Gate | +|---|---|---| +| Format Check | `cargo fmt --all -- --check` | Fails on any formatting drift. | +| Clippy Lint | `cargo clippy --all-targets --all-features -- -D warnings` | Warnings are errors. | +| Test Suite | `cargo test --all` | All tests must pass. | +| Build Check | `cargo build --all` | Native build. | +| Build WASM | `cargo build --package contrib --target wasm32-unknown-unknown --release` | Deployability. Extending this to all contracts is [SC-34]. | +| Dependency Audit | `cargo audit`, `cargo deny check` | Advisories, licences, sources. | +| Code Coverage | `cargo llvm-cov` + `scripts/check-coverage.sh` | Workspace and per-crate floors. | + +### Dependency audit + +[`deny.toml`](deny.toml) configures four checks: advisories, a permissive +licence allowlist, duplicate-version detection, and a crates.io-only source +policy. The job also runs weekly on a schedule, because a lockfile that was +clean at merge time can become vulnerable without anything in the repo +changing. + +Run it locally: + +```sh +cargo install --locked cargo-deny cargo-audit +cd contracts +cargo deny --all-features check +cargo audit --file Cargo.lock --deny warnings +``` + +**Triaging an advisory** — the process is documented inline in `deny.toml`. +The short version: determine whether the vulnerable path is reachable from a +contract entrypoint; if it is, fix it rather than ignoring it; if it genuinely +is not, add it to `ignore` with the reasoning and a review date no more than 90 +days out. + +Two ignores are currently in place, both unmaintained-crate notices for +compile-time proc macros reached through `soroban-sdk` (`derivative`, `paste`). +Neither puts code into a deployed contract. + +Yanked crates are set to `warn` rather than `deny`: `soroban-sdk` 22 pins yanked +versions of `spin` and `keccak` inside its own tree, unresolvable from this +workspace by any `cargo update`. Denying would mean a permanently red job that +everyone learns to ignore. The SDK upgrade in [SC-35] is expected to clear both. + +### Coverage + +```sh +cargo install --locked cargo-llvm-cov +cd contracts +cargo llvm-cov --workspace --lcov --output-path lcov.info +./scripts/check-coverage.sh +``` + +Two gates, and the second is the one that matters: + +1. **Workspace floor** — currently 65%. +2. **Per-crate floor** — a workspace average happily hides a crate with no + tests behind well-tested siblings, which is exactly how `multisig_transfer` + reached 755 lines with zero tests unnoticed. + +Measured when this landed: + +| Crate | Coverage | Floor | +|---|---:|---:| +| `multisig-wallet` | 94% | 90% | +| `asset-maintenance` | 76% | 70% | +| `assetsup` | 74% | 70% | +| `contrib` | 38% | 35% | +| `multisig_transfer` | 0% | exempt | +| **Workspace** | **67%** | **65%** | + +Floors sit a few points under the measured value so ordinary churn does not +fail the build while a real regression does. Ratchet them upward as coverage +improves. + +Any crate at zero coverage **fails**, unless it is named in the +`ZERO_COVERAGE_EXEMPT` list in the script with its tracking issue. +`multisig_transfer` is the only entry, pending [SC-32]. That keeps the build +green for a known, tracked hole while still failing for any new crate that +arrives without tests — removing the entry is how the gate gets tightened. diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index e309bd3aa..ebf91260b 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -16,6 +16,23 @@ members = [ [workspace.dependencies] soroban-sdk = "23" +# Deployable WASM is charged for by size and subject to a ledger size limit, so +# the release profile is tuned for the smallest artifact that is still safe. [profile.release] -opt-level = "z" -overflow-checks = true \ No newline at end of file +opt-level = "z" # optimize for size over speed +lto = true # cross-crate inlining and dead code elimination +codegen-units = 1 # one unit lets LLVM see the whole crate at once +strip = "symbols" # symbol names are dead weight on-chain +debug = 0 # no debug info in a deployed artifact +panic = "abort" # unwinding tables are unusable in Soroban anyway +# Kept deliberately. Overflow checks cost a little size and gas, but these +# contracts move asset value and share counts; a silent wrap is worse than a +# trapped transaction. Converting the traps into typed errors is [SC-43]. +overflow-checks = true + +# Same size profile, but with debug assertions left on so a testnet deployment +# reports precondition failures instead of silently misbehaving. Never use for +# mainnet: debug assertions change failure behaviour and add size. +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/contracts/asset-maintenance/Cargo.toml b/contracts/asset-maintenance/Cargo.toml index d0d91ef19..78d5a0d1b 100644 --- a/contracts/asset-maintenance/Cargo.toml +++ b/contracts/asset-maintenance/Cargo.toml @@ -2,6 +2,8 @@ name = "asset-maintenance" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["cdylib"] diff --git a/contracts/assetsup/Cargo.toml b/contracts/assetsup/Cargo.toml index 6ea6dc4d3..3f35c212c 100644 --- a/contracts/assetsup/Cargo.toml +++ b/contracts/assetsup/Cargo.toml @@ -2,6 +2,8 @@ name = "assetsup" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/contrib/Cargo.toml b/contracts/contrib/Cargo.toml index ab1f712fb..25d4d1a7c 100644 --- a/contracts/contrib/Cargo.toml +++ b/contracts/contrib/Cargo.toml @@ -2,6 +2,8 @@ name = "contrib" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/deny.toml b/contracts/deny.toml new file mode 100644 index 000000000..2177c72e7 --- /dev/null +++ b/contracts/deny.toml @@ -0,0 +1,122 @@ +# cargo-deny configuration for the contracts workspace. +# +# Run locally with: +# cargo install --locked cargo-deny +# cd contracts && cargo deny check +# +# CI runs the same command on every pull request and on a weekly schedule, so +# an advisory published after a merge is still caught. + +[graph] +# Contracts only ever ship to wasm32; native is needed for `cargo test`. +targets = [ + { triple = "wasm32-unknown-unknown" }, + { triple = "x86_64-unknown-linux-gnu" }, +] +all-features = true + +[output] +feature-depth = 1 + +# --------------------------------------------------------------------------- +# Security advisories +# --------------------------------------------------------------------------- +[advisories] +version = 2 +# Any crate with a RUSTSEC *vulnerability* advisory fails the build. These +# contracts govern asset ownership and multisig approvals, so an exploitable +# advisory in a transitive dependency is a release blocker. +# +# Yanked crates are warned about rather than denied. soroban-sdk 22 pins yanked +# versions of `spin` and `keccak` deep in its own tree; neither can be resolved +# from this workspace by any `cargo update`, and a yank is a publishing signal, +# not evidence of a vulnerability. Denying here would mean a permanently red +# build that teaches everyone to ignore this job. Revisit once the SDK upgrade +# in [SC-35] lands, which is expected to move both off the yanked versions. +yanked = "warn" + +# Advisories consciously accepted. Every entry MUST carry: +# - the advisory id +# - why it does not apply here, or what compensating control exists +# - a review date, so an ignore cannot quietly become permanent +# +# Triage process: +# 1. Read the advisory. Determine whether the vulnerable code path is +# reachable from a contract entrypoint. +# 2. If it is reachable, fix it — upgrade, patch, or drop the dependency. +# Do not ignore it. +# 3. If it is genuinely unreachable, or it is unfixable from this workspace +# because it comes from inside soroban-sdk's own tree, add it below with +# the reasoning and a review date no more than 90 days out. +# 4. On the review date, re-check rather than extending by reflex. If the +# dependency is still stuck, say so explicitly and move the date. +# +# Every entry below is transitive through soroban-sdk and cannot be resolved +# from this workspace: nothing here is a direct dependency, and no combination +# of `cargo update` fixes them while the SDK pins what it pins. They are +# unmaintained-or-yanked notices, not exploitable vulnerabilities. +ignore = [ + # `derivative` is unmaintained. Pulled in by soroban-env-host. It is a + # proc-macro used at compile time only; no code from it reaches a + # deployed contract. Review 2026-10-27. + { id = "RUSTSEC-2024-0388", reason = "unmaintained proc-macro, compile-time only, transitive via soroban-env-host; review 2026-10-27" }, + + # `paste` is no longer maintained. Also a compile-time proc-macro reached + # through soroban-sdk. No runtime surface. Review 2026-10-27. + { id = "RUSTSEC-2024-0436", reason = "unmaintained proc-macro, compile-time only, transitive via soroban-sdk; review 2026-10-27" }, +] + +# --------------------------------------------------------------------------- +# Licenses +# --------------------------------------------------------------------------- +[licenses] +version = 2 +# The five workspace crates are never published and carry no `license` field, +# so exempt them. This only affects our own crates; every third-party +# dependency is still checked against the allowlist below. +private = { ignore = true } + +# Permissive licences only. Anything copyleft or unlisted fails and must be +# reviewed deliberately rather than absorbed by accident. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "Unicode-3.0", + "Unlicense", +] +confidence-threshold = 0.8 + +# --------------------------------------------------------------------------- +# Dependency bans +# --------------------------------------------------------------------------- +[bans] +multiple-versions = "warn" +wildcards = "deny" +# contrib dev-depends on assetsup by path, which cargo-deny reads as a +# wildcard version requirement. Path dependencies inside this workspace are +# not a supply-chain risk; wildcards on registry crates still fail. +allow-wildcard-paths = true +# Prefer the highest version when the same crate appears twice, so the warning +# above points at something actionable. +highlight = "all" + +# Duplicate versions that come from the soroban-sdk dependency tree and cannot +# be resolved from this workspace are listed here so the signal stays useful. +skip = [] + +deny = [] + +# --------------------------------------------------------------------------- +# Sources +# --------------------------------------------------------------------------- +[sources] +# Only crates.io. A git or unknown-registry dependency in a contract workspace +# is a supply-chain risk that should be an explicit, reviewed decision. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/contracts/multisig-transfer/Cargo.toml b/contracts/multisig-transfer/Cargo.toml index 5bb969563..a379139ce 100644 --- a/contracts/multisig-transfer/Cargo.toml +++ b/contracts/multisig-transfer/Cargo.toml @@ -2,6 +2,8 @@ name = "multisig-transfer" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/multisig-wallet/Cargo.toml b/contracts/multisig-wallet/Cargo.toml index 70038ef84..7faff53a7 100644 --- a/contracts/multisig-wallet/Cargo.toml +++ b/contracts/multisig-wallet/Cargo.toml @@ -2,6 +2,8 @@ name = "multisig-wallet" version = "0.1.0" edition = "2021" +# Deployed as WASM to Stellar, never published to crates.io. +publish = false [lib] crate-type = ["lib", "cdylib"] diff --git a/contracts/multisig-wallet/src/lib.rs b/contracts/multisig-wallet/src/lib.rs index ef17fe6c8..3cdcbb2cd 100644 --- a/contracts/multisig-wallet/src/lib.rs +++ b/contracts/multisig-wallet/src/lib.rs @@ -35,6 +35,8 @@ mod event_tests; pub mod events; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_coverage; mod types; pub use crate::errors::Error; diff --git a/contracts/multisig-wallet/src/tests_coverage.rs b/contracts/multisig-wallet/src/tests_coverage.rs new file mode 100644 index 000000000..463c83bdc --- /dev/null +++ b/contracts/multisig-wallet/src/tests_coverage.rs @@ -0,0 +1,965 @@ +//! Expanded coverage for the multisig wallet ([SC-40]). +//! +//! `tests.rs` covers four happy paths. This module works through every public +//! entrypoint, the threshold and signer-count invariants at their boundaries, +//! the proposal lifecycle, and the authorization boundaries — the last using +//! `try_*` without `mock_all_auths` so a missing `require_auth` fails the test +//! rather than passing silently. + +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, IntoVal, Symbol, Vec}; + +use crate::errors::Error; +use crate::types::{ProposalStatus, ProposalType, TransactionStatus, TransactionType}; +use crate::{MultisigWallet, MultisigWalletClient}; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +struct Wallet<'a> { + env: Env, + client: MultisigWalletClient<'a>, + admin: Address, + owners: Vec
, +} + +impl<'a> Wallet<'a> { + fn owner(&self, i: u32) -> Address { + self.owners.get(i).unwrap() + } +} + +/// An `n`-owner wallet with the given threshold, with all auths mocked. +fn wallet_with(env: &Env, owner_count: u32, threshold: u32) -> Wallet<'_> { + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(env, &contract_id); + let admin = Address::generate(env); + + let mut owners = Vec::new(env); + for _ in 0..owner_count { + owners.push_back(Address::generate(env)); + } + + env.mock_all_auths(); + client.initialize(&admin, &owners, &threshold); + + Wallet { + env: env.clone(), + client, + admin, + owners, + } +} + +/// The common case: three owners, 2-of-3. +fn wallet(env: &Env) -> Wallet<'_> { + wallet_with(env, 3, 2) +} + +fn submit(w: &Wallet, initiator: &Address) -> u64 { + let target = Address::generate(&w.env); + w.client.submit_transaction( + initiator, + &TransactionType::Routine, + &target, + &Symbol::new(&w.env, "noop"), + &Vec::new(&w.env), + &3600, + &0, + ) +} + +/// Submits and expects failure, returning the contract error. +fn submit_expecting_error(w: &Wallet, initiator: &Address) -> Error { + let target = Address::generate(&w.env); + w.client + .try_submit_transaction( + initiator, + &TransactionType::Routine, + &target, + &Symbol::new(&w.env, "noop"), + &Vec::new(&w.env), + &3600, + &0, + ) + .expect_err("expected submit_transaction to fail") + .expect("expected a contract error, not a host error") +} + +// --------------------------------------------------------------------------- +// initialize — threshold and signer-count invariants at their boundaries +// --------------------------------------------------------------------------- + +#[test] +fn initialize_sets_owners_threshold_and_defaults() { + let env = Env::default(); + let w = wallet(&env); + + assert_eq!(w.client.get_owners().len(), 3); + assert_eq!(w.client.get_threshold(), 2); + assert_eq!(w.client.get_required_confirmations(), 2); + assert!(!w.client.is_frozen()); +} + +#[test] +fn initialize_creates_a_profile_for_every_owner() { + let env = Env::default(); + let w = wallet(&env); + + for i in 0..3 { + let profile = w + .client + .get_owner_profile(&w.owner(i)) + .expect("every owner gets a profile"); + assert!(profile.is_active); + assert_eq!(profile.voting_weight, 1); + assert_eq!(profile.added_by, w.admin); + } +} + +#[test] +fn initialize_twice_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_initialize(&w.admin, &w.owners, &2) + .expect_err("second initialize must fail"); + assert_eq!(res, Ok(Error::AlreadyInitialized)); +} + +#[test] +fn initialize_rejects_fewer_than_two_owners() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let single = Vec::from_array(&env, [Address::generate(&env)]); + let res = client + .try_initialize(&admin, &single, &1) + .expect_err("a one-owner multisig is not a multisig"); + assert_eq!(res, Ok(Error::InsufficientOwners)); +} + +#[test] +fn initialize_rejects_zero_threshold() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let owners = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let res = client + .try_initialize(&admin, &owners, &0) + .expect_err("a zero threshold would let anyone execute"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn initialize_rejects_threshold_greater_than_owner_count() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let admin = Address::generate(&env); + env.mock_all_auths(); + + let owners = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let res = client + .try_initialize(&admin, &owners, &3) + .expect_err("an unreachable threshold would brick the wallet"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn initialize_accepts_threshold_equal_to_owner_count() { + // The upper boundary is valid: unanimity is a legitimate configuration. + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + assert_eq!(w.client.get_threshold(), 3); +} + +// --------------------------------------------------------------------------- +// submit_transaction +// --------------------------------------------------------------------------- + +#[test] +fn submit_transaction_assigns_sequential_ids() { + let env = Env::default(); + let w = wallet(&env); + + assert_eq!(submit(&w, &w.owner(0)), 1); + assert_eq!(submit(&w, &w.owner(1)), 2); + assert_eq!(submit(&w, &w.owner(0)), 3); +} + +#[test] +fn submit_transaction_records_threshold_and_pending_status() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.status, TransactionStatus::Pending); + assert_eq!(tx.required_confirmations, 2); + assert_eq!(tx.confirmations_count, 0); + assert_eq!(tx.initiator, w.owner(0)); +} + +#[test] +fn submit_transaction_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + assert_eq!(submit_expecting_error(&w, &stranger), Error::NotAnOwner); +} + +#[test] +fn get_transaction_returns_none_for_unknown_id() { + let env = Env::default(); + let w = wallet(&env); + assert!(w.client.get_transaction(&999).is_none()); +} + +// --------------------------------------------------------------------------- +// confirm_transaction +// --------------------------------------------------------------------------- + +#[test] +fn confirm_transaction_increments_the_count() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1); + assert_eq!(tx.status, TransactionStatus::Pending); +} + +#[test] +fn duplicate_confirmation_by_the_same_owner_is_rejected() { + // Otherwise one owner could reach any threshold alone. + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + let res = w + .client + .try_confirm_transaction(&w.owner(0), &tx_id) + .expect_err("a second confirmation from the same owner must fail"); + assert_eq!(res, Ok(Error::AlreadyConfirmed)); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1, "count must not have moved"); +} + +#[test] +fn confirm_transaction_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + let stranger = Address::generate(&env); + + let res = w + .client + .try_confirm_transaction(&stranger, &tx_id) + .expect_err("non-owners cannot confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn confirm_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_confirm_transaction(&w.owner(0), &4242) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +#[test] +fn confirm_transaction_rejects_an_expired_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + // Move the ledger past the 3600s deadline set at submission. + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + + let res = w + .client + .try_confirm_transaction(&w.owner(0), &tx_id) + .expect_err("past the deadline"); + assert_eq!(res, Ok(Error::TransactionExpired)); +} + +#[test] +fn expired_status_is_never_persisted_because_the_call_reverts() { + // confirm_transaction sets tx.status = Expired and writes it before + // returning Err(TransactionExpired). Soroban rolls back all storage writes + // when an invocation returns an error, so that write never lands and the + // transaction stays Pending forever. + // + // Pinning the real behaviour rather than the apparent intent: nothing can + // currently move a transaction into Expired, so any consumer filtering on + // that status will never match. Giving it a real transition would need a + // separate entrypoint that expires without erroring. + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7200); + let _ = w.client.try_confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!( + tx.status, + TransactionStatus::Pending, + "the Expired write is rolled back with the failed invocation" + ); +} + +// --------------------------------------------------------------------------- +// revoke_confirmation +// --------------------------------------------------------------------------- + +#[test] +fn revoke_confirmation_decrements_the_count() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + w.client.revoke_confirmation(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 0); +} + +#[test] +fn revoking_allows_the_same_owner_to_confirm_again() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.confirm_transaction(&w.owner(0), &tx_id); + w.client.revoke_confirmation(&w.owner(0), &tx_id); + w.client.confirm_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.confirmations_count, 1); +} + +#[test] +fn revoke_without_a_prior_confirmation_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let res = w + .client + .try_revoke_confirmation(&w.owner(1), &tx_id) + .expect_err("nothing to revoke"); + assert_eq!(res, Ok(Error::Unauthorized)); +} + +#[test] +fn revoke_confirmation_rejects_a_non_owner() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + let stranger = Address::generate(&env); + + let res = w + .client + .try_revoke_confirmation(&stranger, &tx_id) + .expect_err("non-owners cannot revoke"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +// --------------------------------------------------------------------------- +// cancel_transaction +// --------------------------------------------------------------------------- + +#[test] +fn initiator_can_cancel_their_own_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.status, TransactionStatus::Revoked); +} + +#[test] +fn a_different_owner_cannot_cancel_someone_elses_transaction() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + let res = w + .client + .try_cancel_transaction(&w.owner(1), &tx_id) + .expect_err("only the initiator may cancel"); + assert_eq!(res, Ok(Error::Unauthorized)); +} + +#[test] +fn a_cancelled_transaction_cannot_be_confirmed() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let res = w + .client + .try_confirm_transaction(&w.owner(1), &tx_id) + .expect_err("cancelled transactions are closed"); + assert_eq!(res, Ok(Error::TransactionAlreadyExecuted)); +} + +#[test] +fn cancel_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_cancel_transaction(&w.owner(0), &7) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +// --------------------------------------------------------------------------- +// execute_transaction +// --------------------------------------------------------------------------- + +#[test] +fn execute_transaction_rejects_an_unknown_transaction() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_execute_transaction(&123) + .expect_err("unknown transaction"); + assert_eq!(res, Ok(Error::TransactionNotFound)); +} + +#[test] +fn a_cancelled_transaction_cannot_be_executed() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + w.client.cancel_transaction(&w.owner(0), &tx_id); + + let res = w + .client + .try_execute_transaction(&tx_id) + .expect_err("cancelled transactions are closed"); + assert_eq!(res, Ok(Error::TransactionAlreadyExecuted)); +} + +// --------------------------------------------------------------------------- +// Proposal lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn propose_add_owner_creates_a_pending_proposal() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + let p = w.client.get_proposal(&id).unwrap(); + assert_eq!(p.proposal_type, ProposalType::AddOwner); + assert_eq!(p.status, ProposalStatus::Pending); + assert_eq!(p.target_address, Some(candidate)); + assert_eq!(p.confirmations_received, 0); +} + +#[test] +fn proposing_an_existing_owner_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_add_owner(&w.owner(0), &w.owner(1)) + .expect_err("already an owner"); + assert_eq!(res, Ok(Error::OwnerAlreadyExists)); +} + +#[test] +fn proposing_removal_of_a_non_owner_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_propose_remove_owner(&w.owner(0), &stranger) + .expect_err("not an owner"); + assert_eq!(res, Ok(Error::OwnerNotFound)); +} + +#[test] +fn removal_that_would_drop_below_the_threshold_is_rejected() { + // The boundary that matters: a 2-of-2 wallet cannot shed an owner, because + // the remaining owner could never reach the threshold again. + let env = Env::default(); + let w = wallet_with(&env, 2, 2); + + let res = w + .client + .try_propose_remove_owner(&w.owner(0), &w.owner(1)) + .expect_err("removal would make the threshold unreachable"); + assert_eq!(res, Ok(Error::InsufficientOwners)); +} + +#[test] +fn a_non_owner_cannot_raise_a_proposal() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_propose_change_threshold(&stranger, &1) + .expect_err("non-owners cannot propose"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn proposing_a_zero_threshold_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_change_threshold(&w.owner(0), &0) + .expect_err("zero threshold"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn proposing_a_threshold_above_the_owner_count_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_propose_change_threshold(&w.owner(0), &9) + .expect_err("unreachable threshold"); + assert_eq!(res, Ok(Error::InvalidThreshold)); +} + +#[test] +fn duplicate_proposal_confirmation_is_rejected() { + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + w.client.confirm_proposal(&w.owner(0), &id); + let res = w + .client + .try_confirm_proposal(&w.owner(0), &id) + .expect_err("one confirmation per owner"); + assert_eq!(res, Ok(Error::AlreadyConfirmed)); +} + +#[test] +fn a_non_owner_cannot_confirm_a_proposal() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + let stranger = Address::generate(&env); + + let res = w + .client + .try_confirm_proposal(&stranger, &id) + .expect_err("non-owners cannot confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn confirming_an_unknown_proposal_is_rejected() { + let env = Env::default(); + let w = wallet(&env); + + let res = w + .client + .try_confirm_proposal(&w.owner(0), &555) + .expect_err("unknown proposal"); + assert_eq!(res, Ok(Error::ProposalNotFound)); +} + +#[test] +fn reaching_the_threshold_adds_the_owner_and_marks_the_proposal_executed() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert!(w.client.get_owners().contains(candidate.clone())); + assert_eq!( + w.client.get_proposal(&id).unwrap().status, + ProposalStatus::Executed + ); + assert!( + w.client.get_owner_profile(&candidate).is_some(), + "a new owner must get a profile" + ); +} + +#[test] +fn an_executed_proposal_cannot_be_executed_again() { + let env = Env::default(); + let w = wallet(&env); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + let res = w + .client + .try_execute_proposal(&id) + .expect_err("re-execution must be rejected"); + assert_eq!(res, Ok(Error::InvalidProposal)); + + assert_eq!( + w.client.get_owners().len(), + 4, + "the owner must not be added twice" + ); +} + +#[test] +fn a_proposal_below_the_threshold_cannot_be_executed() { + let env = Env::default(); + let w = wallet_with(&env, 3, 3); + let candidate = Address::generate(&env); + let id = w.client.propose_add_owner(&w.owner(0), &candidate); + w.client.confirm_proposal(&w.owner(0), &id); + + let res = w + .client + .try_execute_proposal(&id) + .expect_err("one of three confirmations is not enough"); + assert_eq!(res, Ok(Error::Unauthorized)); + assert!(!w.client.get_owners().contains(candidate)); +} + +#[test] +fn removing_an_owner_drops_their_profile() { + let env = Env::default(); + let w = wallet(&env); + let victim = w.owner(2); + let id = w.client.propose_remove_owner(&w.owner(0), &victim); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert!(!w.client.get_owners().contains(victim.clone())); + assert!( + w.client.get_owner_profile(&victim).is_none(), + "a removed owner must not keep a profile" + ); +} + +#[test] +fn a_removed_owner_can_no_longer_confirm() { + // The security property behind SC-40: removal must actually revoke power. + let env = Env::default(); + let w = wallet(&env); + let victim = w.owner(2); + + let removal = w.client.propose_remove_owner(&w.owner(0), &victim); + w.client.confirm_proposal(&w.owner(0), &removal); + w.client.confirm_proposal(&w.owner(1), &removal); + assert!(!w.client.get_owners().contains(victim.clone())); + + let tx_id = submit(&w, &w.owner(0)); + let res = w + .client + .try_confirm_transaction(&victim, &tx_id) + .expect_err("a removed owner must not be able to confirm"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn executing_a_threshold_proposal_changes_the_threshold() { + let env = Env::default(); + let w = wallet(&env); + let id = w.client.propose_change_threshold(&w.owner(0), &3); + + w.client.confirm_proposal(&w.owner(0), &id); + w.client.confirm_proposal(&w.owner(1), &id); + + assert_eq!(w.client.get_threshold(), 3); + assert_eq!(w.client.get_required_confirmations(), 3); +} + +#[test] +fn get_proposal_returns_none_for_unknown_id() { + let env = Env::default(); + let w = wallet(&env); + assert!(w.client.get_proposal(&321).is_none()); +} + +// --------------------------------------------------------------------------- +// Freeze, unfreeze, daily limit +// --------------------------------------------------------------------------- + +#[test] +fn freezing_blocks_submission_and_unfreezing_restores_it() { + let env = Env::default(); + let w = wallet(&env); + + w.client.emergency_freeze(&w.owner(0)); + assert!(w.client.is_frozen()); + + assert_eq!(submit_expecting_error(&w, &w.owner(0)), Error::WalletFrozen); + + w.client.emergency_unfreeze(&w.owner(0)); + assert!(!w.client.is_frozen()); + assert_eq!(submit(&w, &w.owner(0)), 1); +} + +#[test] +fn freezing_blocks_confirmation() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.emergency_freeze(&w.owner(0)); + + let res = w + .client + .try_confirm_transaction(&w.owner(1), &tx_id) + .expect_err("frozen wallets reject confirmations"); + assert_eq!(res, Ok(Error::WalletFrozen)); +} + +#[test] +fn reads_still_work_while_frozen() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + w.client.emergency_freeze(&w.owner(0)); + + assert!(w.client.is_frozen()); + assert_eq!(w.client.get_threshold(), 2); + assert_eq!(w.client.get_owners().len(), 3); + assert!(w.client.get_transaction(&tx_id).is_some()); +} + +#[test] +fn a_non_owner_cannot_freeze_or_unfreeze() { + let env = Env::default(); + let w = wallet(&env); + let stranger = Address::generate(&env); + + let res = w + .client + .try_emergency_freeze(&stranger) + .expect_err("non-owners cannot freeze"); + assert_eq!(res, Ok(Error::NotAnOwner)); + + w.client.emergency_freeze(&w.owner(0)); + let res = w + .client + .try_emergency_unfreeze(&stranger) + .expect_err("non-owners cannot unfreeze"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +#[test] +fn set_daily_limit_stores_the_limit() { + let env = Env::default(); + let w = wallet(&env); + + w.client.set_daily_limit(&w.owner(0), &5_000u128); + // No getter is exposed; the limit is observable through its effect on + // execution, so assert the call is accepted from an owner and rejected + // from a stranger. + let stranger = Address::generate(&env); + let res = w + .client + .try_set_daily_limit(&stranger, &1u128) + .expect_err("non-owners cannot change the limit"); + assert_eq!(res, Ok(Error::NotAnOwner)); +} + +// --------------------------------------------------------------------------- +// Authorization — no mock_all_auths +// --------------------------------------------------------------------------- + +/// Builds a wallet, then clears the mocked auths so subsequent calls must +/// carry real authorization. +fn wallet_then_drop_auths(env: &Env) -> Wallet<'_> { + let w = wallet(env); + env.set_auths(&[]); + w +} + +#[test] +fn submit_transaction_requires_the_initiators_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + + let target = Address::generate(&env); + let res = w.client.try_submit_transaction( + &w.owner(0), + &TransactionType::Routine, + &target, + &Symbol::new(&env, "noop"), + &Vec::new(&env), + &3600, + &0, + ); + assert!( + res.is_err(), + "submit_transaction must fail without the initiator's auth" + ); +} + +#[test] +fn confirm_transaction_requires_the_confirmers_authorization() { + let env = Env::default(); + let w = wallet(&env); + let tx_id = submit(&w, &w.owner(0)); + + env.set_auths(&[]); + + let res = w.client.try_confirm_transaction(&w.owner(1), &tx_id); + assert!( + res.is_err(), + "confirm_transaction must fail without the confirmer's auth" + ); +} + +#[test] +fn emergency_freeze_requires_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + + let res = w.client.try_emergency_freeze(&w.owner(0)); + assert!( + res.is_err(), + "emergency_freeze must fail without the caller's auth" + ); +} + +#[test] +fn propose_add_owner_requires_the_proposers_authorization() { + let env = Env::default(); + let w = wallet_then_drop_auths(&env); + let candidate = Address::generate(&env); + + let res = w.client.try_propose_add_owner(&w.owner(0), &candidate); + assert!( + res.is_err(), + "propose_add_owner must fail without the proposer's auth" + ); +} + +// --------------------------------------------------------------------------- +// Error reachability +// --------------------------------------------------------------------------- + +#[test] +fn every_error_variant_asserted_here_is_distinct() { + // Guards against two variants collapsing onto the same discriminant during + // a renumbering, which would make the assertions above vacuous. + let codes = [ + Error::AlreadyInitialized as u32, + Error::NotInitialized as u32, + Error::Unauthorized as u32, + Error::InvalidThreshold as u32, + Error::InsufficientOwners as u32, + Error::TransactionNotFound as u32, + Error::TransactionAlreadyExecuted as u32, + Error::TransactionExpired as u32, + Error::AlreadyConfirmed as u32, + Error::OwnerAlreadyExists as u32, + Error::OwnerNotFound as u32, + Error::ProposalNotFound as u32, + Error::WalletFrozen as u32, + Error::InvalidProposal as u32, + Error::NotAnOwner as u32, + ]; + + for (i, a) in codes.iter().enumerate() { + for b in codes.iter().skip(i + 1) { + assert_ne!(a, b, "error discriminants must be unique"); + } + } +} + +#[test] +fn uninitialized_wallet_reports_not_initialized() { + let env = Env::default(); + let contract_id = env.register(MultisigWallet, ()); + let client = MultisigWalletClient::new(&env, &contract_id); + let stranger = Address::generate(&env); + env.mock_all_auths(); + + let target = Address::generate(&env); + let res = client + .try_submit_transaction( + &stranger, + &TransactionType::Routine, + &target, + &Symbol::new(&env, "noop"), + &Vec::new(&env), + &3600, + &0, + ) + .expect_err("nothing is initialized"); + assert_eq!(res, Ok(Error::NotInitialized)); +} + +#[test] +fn transaction_parameters_round_trip() { + let env = Env::default(); + let w = wallet(&env); + let target = Address::generate(&env); + let params: Vec = Vec::from_array(&env, [42u32.into_val(&env)]); + + let tx_id = w.client.submit_transaction( + &w.owner(0), + &TransactionType::Transfer, + &target, + &Symbol::new(&env, "pay"), + ¶ms, + &3600, + &100, + ); + + let tx = w.client.get_transaction(&tx_id).unwrap(); + assert_eq!(tx.tx_type, TransactionType::Transfer); + assert_eq!(tx.target, target); + assert_eq!(tx.function_name, Symbol::new(&env, "pay")); + assert_eq!(tx.parameters.len(), 1); + assert_eq!(tx.value, 100); +} diff --git a/contracts/scripts/check-coverage.sh b/contracts/scripts/check-coverage.sh new file mode 100755 index 000000000..b9620ebbd --- /dev/null +++ b/contracts/scripts/check-coverage.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Enforces coverage thresholds for the contracts workspace. +# +# Two separate gates: +# +# 1. A workspace-wide line coverage floor. +# 2. A per-crate floor. +# +# The per-crate floor is the one that matters. A workspace average happily +# hides a crate with no tests at all behind well-tested siblings — which is +# exactly how multisig_transfer reached 755 lines with zero tests unnoticed. +# +# Thresholds are set at the currently measured level, not an aspirational one, +# so the build stays green today and can only be ratcheted upward deliberately. +# +# Usage: +# cargo llvm-cov --workspace --lcov --output-path lcov.info +# ./scripts/check-coverage.sh [lcov.info] + +set -euo pipefail + +LCOV_FILE="${1:-lcov.info}" + +# --------------------------------------------------------------------------- +# Thresholds — raise these as coverage improves; never lower them silently. +# --------------------------------------------------------------------------- +WORKSPACE_MIN=65 + +# Per-crate line coverage floors, keyed by crate directory name. Each is set a +# little below the level measured when this landed, so ordinary churn does not +# fail the build but a real regression does. Ratchet these upward as coverage +# improves; never lower one without saying why in the PR. +# +# Measured at time of writing: +# assetsup 74% contrib 38% multisig-wallet 94% asset-maintenance 76% +crate_floor() { + case "$1" in + assetsup) echo 70 ;; + contrib) echo 35 ;; + multisig-wallet) echo 90 ;; + asset-maintenance) echo 70 ;; + # A crate with no tests must fail — see ZERO_COVERAGE_EXEMPT below. + *) echo 1 ;; + esac +} + +# Crates allowed to sit at zero coverage, each with the issue tracking the gap. +# This is the escape hatch that keeps the build green for a known, tracked hole +# while still failing for any *new* crate that arrives without tests. Removing +# an entry here is how the gate gets tightened. +# +# multisig_transfer — 755 lines, zero tests. Tracked in [SC-32]. +ZERO_COVERAGE_EXEMPT="multisig_transfer" + +is_zero_coverage_exempt() { + for exempt in $ZERO_COVERAGE_EXEMPT; do + [[ "$1" == "$exempt" ]] && return 0 + done + return 1 +} + +CRATES="assetsup contrib multisig-wallet asset-maintenance multisig_transfer" + +if [[ ! -f "$LCOV_FILE" ]]; then + echo "error: $LCOV_FILE not found. Run cargo llvm-cov first." >&2 + exit 1 +fi + +# Sums LF (lines found) and LH (lines hit) from lcov records whose SF path +# matches the given prefix. Prints "hit total". +sum_for_prefix() { + local prefix="$1" + awk -v prefix="$prefix" ' + /^SF:/ { + path = substr($0, 4) + want = (index(path, prefix) > 0) + } + want && /^LF:/ { total += substr($0, 4) } + want && /^LH:/ { hit += substr($0, 4) } + END { printf "%d %d\n", hit + 0, total + 0 } + ' "$LCOV_FILE" +} + +pct() { + local hit="$1" total="$2" + if [[ "$total" -eq 0 ]]; then + echo 0 + else + echo $(( hit * 100 / total )) + fi +} + +failed=0 + +echo "Per-crate line coverage" +echo "-----------------------------------------------" +printf '%-20s %8s %8s %8s\n' "crate" "covered" "floor" "result" + +for crate in $CRATES; do + read -r hit total <<<"$(sum_for_prefix "contracts/$crate/")" + + # Fall back to a repo-relative path if coverage was run from contracts/. + if [[ "$total" -eq 0 ]]; then + read -r hit total <<<"$(sum_for_prefix "/$crate/src/")" + fi + + floor="$(crate_floor "$crate")" + + if [[ "$total" -eq 0 ]]; then + printf '%-20s %8s %7s%% %8s\n' "$crate" "no data" "$floor" "SKIP" + continue + fi + + covered="$(pct "$hit" "$total")" + + if [[ "$covered" -eq 0 ]] && is_zero_coverage_exempt "$crate"; then + printf '%-20s %7s%% %7s%% %8s\n' "$crate" "$covered" "-" "EXEMPT" + echo " ^ zero coverage, exempt pending its tracking issue; see the" \ + "ZERO_COVERAGE_EXEMPT list in this script." + continue + fi + + if [[ "$covered" -lt "$floor" ]]; then + printf '%-20s %7s%% %7s%% %8s\n' "$crate" "$covered" "$floor" "FAIL" + failed=1 + else + printf '%-20s %7s%% %7s%% %8s\n' "$crate" "$covered" "$floor" "ok" + fi +done + +echo + +read -r total_hit total_lines <<<"$(sum_for_prefix "/src/")" +workspace_pct="$(pct "$total_hit" "$total_lines")" + +echo "Workspace: ${workspace_pct}% (${total_hit}/${total_lines} lines), floor ${WORKSPACE_MIN}%" + +if [[ "$workspace_pct" -lt "$WORKSPACE_MIN" ]]; then + echo "FAIL: workspace coverage ${workspace_pct}% is below the ${WORKSPACE_MIN}% floor" >&2 + failed=1 +fi + +if [[ "$failed" -ne 0 ]]; then + echo >&2 + echo "Coverage thresholds not met. Add tests, or change the floor in" >&2 + echo "contracts/scripts/check-coverage.sh with a reason in the PR." >&2 + exit 1 +fi + +echo "All coverage thresholds met." From 96611d951f40470039e15f13dc4738e09e41d802 Mon Sep 17 00:00:00 2001 From: amberly-d <294444926+amberly-d@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:24:16 +0100 Subject: [PATCH 2/3] Fix a real advisory the audit job caught, and align it with deny.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dependency Audit job failed on its first CI run, for two reasons — one a genuine finding, which is the job doing its job. The finding: RUSTSEC-2026-0009, a medium-severity denial of service via stack exhaustion in time 0.3.44, reachable through the soroban dependency tree. It has a fix, so it is fixed rather than ignored: cargo update -p time moves it to 0.3.54, a lockfile-only change. cargo audit is now clean of vulnerabilities. The inconsistency: the job ran cargo audit with --deny warnings, which promotes *yanked* crates to errors. That contradicts the policy deny.toml already documents — soroban-sdk pins yanked spin and keccak inside its own tree, which no cargo update can resolve, so denying there would mean a permanently red job that everyone learns to ignore. The two tools now agree: cargo audit gates on actual vulnerabilities, and cargo deny handles yanked and unmaintained advisories with a recorded reason and review date for each. Verified locally: cargo audit and cargo deny both exit 0, and all 435 tests pass. --- .github/workflows/CI.yaml | 8 +++++++- contracts/Cargo.lock | 24 +++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 3182a8371..a48748ed5 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -198,8 +198,14 @@ jobs: command -v cargo-audit >/dev/null || cargo install --locked cargo-audit command -v cargo-deny >/dev/null || cargo install --locked cargo-deny + # Gates on actual vulnerabilities. Deliberately not --deny warnings: + # that promotes *yanked* crates to errors, and soroban-sdk pins yanked + # versions of spin and keccak inside its own tree that no cargo update + # can resolve. Yanked and unmaintained advisories are handled by cargo + # deny below, where deny.toml records each one with a reason and a review + # date rather than failing the build indefinitely. - name: cargo audit - run: cargo audit --file Cargo.lock --deny warnings + run: cargo audit --file Cargo.lock # Covers advisories, the license allowlist, duplicate versions, and # source registries. See contracts/deny.toml for the triage process. diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 03925589c..68edb4bcf 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -453,11 +453,10 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -901,9 +900,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1589,30 +1588,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", From cc270a50eb5f76a100bc8261658258c0003005cb Mon Sep 17 00:00:00 2001 From: portable Date: Mon, 27 Jul 2026 18:08:44 +0100 Subject: [PATCH 3/3] fix: configure Jest for JSX transform, remove vitest imports, add @testing-library/react --- .../AssetHistoryComponent.spec.tsx | 1 - .../AuthFlow/AuthFlowComponent.spec.tsx | 1 - .../LoadingSkeletonsComponent.spec.tsx | 1 - .../LocationsManagementComponent.spec.tsx | 1 - frontend/jest.config.js | 19 ++ frontend/package-lock.json | 240 ++++++++++++++++++ frontend/package.json | 2 + 7 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 frontend/jest.config.js diff --git a/frontend/features/AssetHistory/AssetHistoryComponent.spec.tsx b/frontend/features/AssetHistory/AssetHistoryComponent.spec.tsx index ba93b537c..76bf6aa42 100644 --- a/frontend/features/AssetHistory/AssetHistoryComponent.spec.tsx +++ b/frontend/features/AssetHistory/AssetHistoryComponent.spec.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { AssetHistoryComponent } from "./AssetHistoryComponent"; describe("AssetHistoryComponent", () => { diff --git a/frontend/features/AuthFlow/AuthFlowComponent.spec.tsx b/frontend/features/AuthFlow/AuthFlowComponent.spec.tsx index fe8f4b573..66528ad89 100644 --- a/frontend/features/AuthFlow/AuthFlowComponent.spec.tsx +++ b/frontend/features/AuthFlow/AuthFlowComponent.spec.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { AuthFlowComponent } from "./AuthFlowComponent"; describe("AuthFlowComponent", () => { diff --git a/frontend/features/LoadingSkeletons/LoadingSkeletonsComponent.spec.tsx b/frontend/features/LoadingSkeletons/LoadingSkeletonsComponent.spec.tsx index 40318fdde..6307d4ba1 100644 --- a/frontend/features/LoadingSkeletons/LoadingSkeletonsComponent.spec.tsx +++ b/frontend/features/LoadingSkeletons/LoadingSkeletonsComponent.spec.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { describe, it, expect } from "vitest"; import { render } from "@testing-library/react"; import { LoadingSkeletonsComponent } from "./LoadingSkeletonsComponent"; describe("LoadingSkeletonsComponent", () => { diff --git a/frontend/features/LocationsManagement/LocationsManagementComponent.spec.tsx b/frontend/features/LocationsManagement/LocationsManagementComponent.spec.tsx index 138b8ebf1..217921fa7 100644 --- a/frontend/features/LocationsManagement/LocationsManagementComponent.spec.tsx +++ b/frontend/features/LocationsManagement/LocationsManagementComponent.spec.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { LocationsManagementComponent } from "./LocationsManagementComponent"; describe("LocationsManagementComponent", () => { diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 000000000..80dd39e33 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,19 @@ +/** @type {import('jest').Config} */ +const config = { + testEnvironment: "jsdom", + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: { + jsx: "react-jsx", + }, + }, + ], + }, + moduleNameMapper: { + "^@/(.*)$": "/$1", + }, +}; + +module.exports = config; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a376a595..3f98ad6ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/qrcode": "^1.5.6", @@ -54,6 +56,13 @@ "typescript": "^5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2747,6 +2756,145 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2758,6 +2906,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4572,6 +4728,13 @@ "utrie": "^1.0.2" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -4915,6 +5078,17 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4954,6 +5128,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -6612,6 +6794,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8385,6 +8577,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8499,6 +8702,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -9671,6 +9884,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -10583,6 +10810,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 71a9245b4..79ddbacbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/node": "^20", "@types/qrcode": "^1.5.6",