freenet 0.2.67

Freenet core software
Documentation
//! Regression tests for issue #4151 — same-version merge-rejected spam.
//!
//! When `update_state` WASM returns `InvalidUpdateWithInfo` for an idempotent
//! re-push (incoming version == current version), the `merge_rejected_valid_local`
//! event MUST:
//!   1. Be classified as `is_invalid_update_rejection()` = true (the predicate
//!      that gates log severity).
//!   2. NOT fire at INFO level (this is tested indirectly via the predicate).
//!
//! Additionally, when the incoming state bytes are byte-identical to the stored
//! state, the short-circuit MUST return `UpsertResult::NoChange` without invoking
//! the WASM `update_state` function at all (verified by a spy-counted override).

use either::Either;
use freenet_stdlib::prelude::*;
use std::sync::Arc;

use crate::contract::executor::mock_wasm_runtime::{MockWasmRuntime, UpdateOverride};
use crate::contract::executor::{ContractExecutor, Executor, UpsertResult};
use crate::wasm_runtime::MockStateStorage;

/// Create a MockWasmRuntime executor.
async fn create_executor() -> Executor<MockWasmRuntime, MockStateStorage> {
    let storage = MockStateStorage::new();
    Executor::new_mock_wasm("merge_rejected_test", storage, None, None)
        .await
        .expect("create MockWasmRuntime executor")
}

/// Create a contract with the given code bytes and empty params.
fn make_contract(code_bytes: &[u8]) -> ContractContainer {
    let code = ContractCode::from(code_bytes.to_vec());
    let params = Parameters::from(vec![]);
    ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
        Arc::new(code),
        params,
    )))
}

// =========================================================================
// Test: same-version rejection error is classified as is_invalid_update_rejection
// =========================================================================

/// Regression test for #4151.
///
/// When the contract WASM's `update_state` returns `InvalidUpdateWithInfo`
/// (e.g. "New state version X must be higher than current version X"), the
/// resulting `ExecutorError` MUST satisfy `is_invalid_update_rejection()`.
/// The production code uses this predicate to decide whether to log at DEBUG
/// (benign idempotent push) or INFO (unexpected merge failure).
#[tokio::test(flavor = "current_thread")]
async fn test_same_version_rejection_is_classified_as_invalid_update() {
    let mut executor = create_executor().await;

    // PUT the contract with initial state.
    let contract = make_contract(b"same_version_test");
    let key = contract.key();
    let initial_state = WrappedState::new(vec![1, 2, 3, 4]);

    executor
        .upsert_contract_state(
            key,
            Either::Left(initial_state.clone()),
            RelatedContracts::default(),
            Some(contract),
        )
        .await
        .expect("initial PUT should succeed");

    // Wire the same-version rejection override so update_state returns
    // InvalidUpdateWithInfo (exactly what website-contract emits when
    // new_version <= current_version).
    let version_rejection_reason =
        "New state version 100 must be higher than current version 100".to_string();
    executor.runtime.update_overrides.insert(
        *key.id(),
        UpdateOverride::RejectInvalidUpdate {
            reason: version_rejection_reason.clone(),
        },
    );

    // Attempt to update with a different state (different bytes so the
    // byte-equality short-circuit does NOT fire — we want the WASM path).
    let different_state = WrappedState::new(vec![5, 6, 7, 8]);
    let err = executor
        .upsert_contract_state(
            key,
            Either::Left(different_state),
            RelatedContracts::default(),
            None,
        )
        .await
        .expect_err("same-version update should fail");

    // The error MUST be recognized as a benign invalid-update rejection.
    // This is the predicate the production code uses to downgrade the log
    // level from INFO to DEBUG (issue #4151).
    assert!(
        err.is_invalid_update_rejection(),
        "same-version WASM rejection must satisfy is_invalid_update_rejection(); \
         got: {err:?}"
    );

    // It must also satisfy the broader is_contract_exec_rejection predicate
    // (auto-fetch gate).
    assert!(
        err.is_contract_exec_rejection(),
        "same-version rejection must also satisfy is_contract_exec_rejection()"
    );
}

// =========================================================================
// Test: byte-identical state update short-circuits without calling WASM
// =========================================================================

/// Regression test for #4151 (short-circuit).
///
/// When the incoming state bytes are identical to the stored state bytes,
/// `upsert_contract_state` MUST return `UpsertResult::NoChange` immediately
/// without invoking `update_state` in the WASM contract.  This avoids a
/// pointless WASM round-trip — and, critically, suppresses the spurious
/// `merge_rejected_valid_local` INFO log that fires when the contract's
/// version-gating logic rejects an idempotent re-push.
///
/// We verify the short-circuit by wiring a `RejectInvalidUpdate` override:
/// if `update_state` were called, the executor would return `Err`; if the
/// short-circuit fires, we get `Ok(UpsertResult::NoChange)`.
#[tokio::test(flavor = "current_thread")]
async fn test_identical_state_short_circuits_before_wasm_update() {
    let mut executor = create_executor().await;

    // PUT the contract with initial state.
    let contract = make_contract(b"short_circuit_test");
    let key = contract.key();
    let initial_state = WrappedState::new(vec![10, 20, 30, 40]);

    executor
        .upsert_contract_state(
            key,
            Either::Left(initial_state.clone()),
            RelatedContracts::default(),
            Some(contract),
        )
        .await
        .expect("initial PUT should succeed");

    // Wire the rejection override so that if update_state is called it errors.
    // The short-circuit must prevent this from being reached.
    executor.runtime.update_overrides.insert(
        *key.id(),
        UpdateOverride::RejectInvalidUpdate {
            reason: "short-circuit spy: update_state must NOT be called for identical state"
                .to_string(),
        },
    );

    // Attempt to update with the SAME state bytes.
    let result = executor
        .upsert_contract_state(
            key,
            Either::Left(initial_state.clone()),
            RelatedContracts::default(),
            None,
        )
        .await
        .expect("identical-state upsert must succeed (short-circuit, not WASM error)");

    assert!(
        matches!(result, UpsertResult::NoChange),
        "byte-identical state update must return NoChange (short-circuit), got: {result:?}"
    );
}

// =========================================================================
// Test: ExecutorError predicate for the inline error message format
// =========================================================================

/// Unit test for the `is_invalid_update_rejection` predicate matching the
/// exact string that `website-contract` emits for same-version pushes.
///
/// This is a plain unit test (no executor needed) that guards against
/// string-format drift between the predicate and the contract's error message.
#[test]
fn test_is_invalid_update_rejection_matches_website_contract_format() {
    use crate::contract::executor::ExecutorError;
    use freenet_stdlib::client_api::ContractError as StdContractError;

    let key = crate::contract::executor::test_fixtures::make_contract_key();
    // Exact string format from website-contract/src/lib.rs:179
    let cause_string = "invalid contract update, reason: \
                        New state version 100 must be higher than current version 100";
    let err = ExecutorError::request(StdContractError::update_exec_error(key, cause_string));

    assert!(
        err.is_invalid_update_rejection(),
        "predicate must match website-contract's same-version error format"
    );
}