wacore 0.7.0

Core WhatsApp protocol implementation without runtime dependencies
Documentation
//! Integration tests for external mutations and hasMissingRemove handling.
//!
//! These tests verify the behavior when:
//! 1. Patches have external_mutations that need to be downloaded
//! 2. REMOVE mutations reference entries we don't have locally (hasMissingRemove)

// Tests/benches exercise the raw buffa API.
#![allow(clippy::disallowed_methods)]

use buffa::Message;
use wacore::appstate::WAPATCH_INTEGRITY;
use wacore::appstate::hash::{HashState, generate_content_mac};
use wacore::appstate::keys::expand_app_state_keys;
use wacore::appstate::processor::validate_patch_macs;
use waproto::whatsapp as wa;

fn make_mutation(
    op: wa::syncd_mutation::SyncdOperation,
    key_id: &[u8],
    index_mac: Vec<u8>,
    value_blob: Option<Vec<u8>>,
) -> wa::SyncdMutation {
    wa::SyncdMutation {
        operation: Some(op.into()),
        record: wa::SyncdRecord {
            index: wa::SyncdIndex {
                blob: Some(index_mac),
            }
            .into(),
            value: match value_blob {
                Some(b) => wa::SyncdValue { blob: Some(b) }.into(),
                None => Default::default(),
            },
            key_id: wa::KeyId {
                id: Some(key_id.to_vec()),
            }
            .into(),
        }
        .into(),
    }
}

fn create_value_blob(
    keys: &wacore::appstate::keys::ExpandedAppStateKeys,
    key_id: &[u8],
) -> Vec<u8> {
    let iv = [1u8; 16];
    let ciphertext = b"test_content".to_vec();
    let mut content = iv.to_vec();
    content.extend_from_slice(&ciphertext);
    let value_mac = generate_content_mac(
        wa::syncd_mutation::SyncdOperation::SET,
        &content,
        key_id,
        &keys.value_mac,
    );
    let mut value_blob = content;
    value_blob.extend_from_slice(&value_mac);
    value_blob
}

#[test]
fn test_has_missing_remove_flag_set_on_remove_without_previous_value() {
    let mut state = HashState::default();
    let key_id = b"test_key";
    let index_mac = vec![1u8; 32];

    // Create a REMOVE mutation for an entry that doesn't exist locally
    let mutations = vec![make_mutation(
        wa::syncd_mutation::SyncdOperation::REMOVE,
        key_id,
        index_mac,
        None,
    )];

    // The callback returns None (entry not found locally)
    let (result, err) = state.update_hash(&mutations, |_, _| Ok(None));

    assert!(err.is_ok());
    assert!(
        result.has_missing_remove,
        "has_missing_remove should be true when REMOVE targets missing entry"
    );
}

#[test]
fn test_has_missing_remove_flag_not_set_on_set_without_previous_value() {
    let mut state = HashState::default();
    let key_id = b"test_key";
    let index_mac = vec![1u8; 32];
    let master_key = [7u8; 32];
    let keys = expand_app_state_keys(&master_key);

    let value_blob = create_value_blob(&keys, key_id);

    // Create a SET mutation (new entry, no previous value)
    let mutations = vec![make_mutation(
        wa::syncd_mutation::SyncdOperation::SET,
        key_id,
        index_mac,
        Some(value_blob),
    )];

    let (result, err) = state.update_hash(&mutations, |_, _| Ok(None));

    assert!(err.is_ok());
    assert!(
        !result.has_missing_remove,
        "has_missing_remove should be false for SET without previous value"
    );
}

#[test]
fn test_has_missing_remove_flag_not_set_when_previous_value_exists() {
    let mut state = HashState::default();
    let key_id = b"test_key";
    let index_mac = vec![1u8; 32];
    let previous_value_mac = vec![99u8; 32];

    // Create a REMOVE mutation for an entry that DOES exist locally
    let mutations = vec![make_mutation(
        wa::syncd_mutation::SyncdOperation::REMOVE,
        key_id,
        index_mac.clone(),
        None,
    )];

    // The callback returns the previous value MAC
    let (result, err) = state.update_hash(&mutations, |idx, _| {
        if idx == index_mac {
            Ok(Some(previous_value_mac.clone()))
        } else {
            Ok(None)
        }
    });

    assert!(err.is_ok());
    assert!(
        !result.has_missing_remove,
        "has_missing_remove should be false when previous value exists"
    );
}

#[test]
fn test_lthash_diverges_on_missing_remove() {
    // When we can't subtract a value (missing remove), our ltHash will diverge
    // from the server's. This test verifies the divergence behavior.

    let master_key = [7u8; 32];
    let keys = expand_app_state_keys(&master_key);
    let key_id = b"test_key";

    // Create initial state with one entry
    let index_mac_1 = vec![1u8; 32];
    let value_blob_1 = create_value_blob(&keys, key_id);
    let value_mac_1 = value_blob_1[value_blob_1.len() - 32..].to_vec();

    let mut state = HashState::default();
    let set_mutation = make_mutation(
        wa::syncd_mutation::SyncdOperation::SET,
        key_id,
        index_mac_1.clone(),
        Some(value_blob_1),
    );
    let (_, err) = state.update_hash(&[set_mutation], |_, _| Ok(None));
    assert!(err.is_ok());

    // Compute expected hash with the entry
    let expected_hash_with_entry = WAPATCH_INTEGRITY.subtract_then_add(
        &[0u8; 128],
        &[] as &[Vec<u8>],
        std::slice::from_ref(&value_mac_1),
    );

    assert_eq!(state.hash.as_slice(), expected_hash_with_entry.as_slice());

    // Now simulate a REMOVE for an entry we DON'T have (index_mac_2)
    let index_mac_2 = vec![2u8; 32];
    let remove_mutation = make_mutation(
        wa::syncd_mutation::SyncdOperation::REMOVE,
        key_id,
        index_mac_2,
        None,
    );

    let (result, err) = state.update_hash(&[remove_mutation], |_, _| Ok(None));
    assert!(err.is_ok());
    assert!(result.has_missing_remove);

    // Our hash should NOT have changed because we couldn't subtract anything
    // (this is the divergence - server subtracted the value, we didn't)
    assert_eq!(
        state.hash.as_slice(),
        expected_hash_with_entry.as_slice(),
        "Hash should remain unchanged when we can't subtract a missing value"
    );
}

#[test]
fn test_external_mutations_decode_from_syncd_mutations() {
    // Test that SyncdMutations (the wrapper for external mutations) can be decoded
    let key_id = b"test_key";
    let master_key = [7u8; 32];
    let keys = expand_app_state_keys(&master_key);

    let mutation1 = make_mutation(
        wa::syncd_mutation::SyncdOperation::SET,
        key_id,
        vec![1u8; 32],
        Some(create_value_blob(&keys, key_id)),
    );

    let mutation2 = make_mutation(
        wa::syncd_mutation::SyncdOperation::REMOVE,
        key_id,
        vec![2u8; 32],
        None,
    );

    // Encode as SyncdMutations (what external_mutations downloads return)
    let syncd_mutations = wa::SyncdMutations {
        mutations: vec![mutation1, mutation2],
    };

    let encoded = syncd_mutations.encode_to_vec();

    // Decode back
    let decoded = wa::SyncdMutations::decode_from_slice(&encoded).expect("should decode");

    assert_eq!(decoded.mutations.len(), 2);
    assert_eq!(
        decoded.mutations[0].operation,
        Some(wa::syncd_mutation::SyncdOperation::SET.into())
    );
    assert_eq!(
        decoded.mutations[1].operation,
        Some(wa::syncd_mutation::SyncdOperation::REMOVE.into())
    );
}

/// `has_missing_remove` is telemetry about the ltHash arithmetic, not a licence
/// to skip a comparison. A `snapshotMac` mismatch is reported as divergence
/// either way — the flag must not change the verdict in either direction.
#[test]
fn test_validate_patch_macs_reports_divergence_regardless_of_has_missing_remove() {
    let master_key = [7u8; 32];
    let keys = expand_app_state_keys(&master_key);
    let key_id = b"test_key";
    let collection_name = "regular_low";

    let state = HashState {
        version: 1,
        hash: [42u8; 128], // arbitrary non-zero hash
        ..Default::default()
    };

    // Create a patch with a snapshot_mac that won't match our state
    let patch = wa::SyncdPatch {
        version: wa::SyncdVersion { version: Some(2) }.into(),
        snapshot_mac: Some(vec![0u8; 32]), // This won't match our computed MAC
        key_id: wa::KeyId {
            id: Some(key_id.to_vec()),
        }
        .into(),
        ..Default::default()
    };

    for has_missing_remove in [false, true] {
        let verdict = validate_patch_macs(
            &patch,
            &state,
            &keys,
            collection_name,
            false,
            has_missing_remove,
        )
        .expect("a snapshotMAC mismatch is divergence, not a rejected patch");
        assert!(
            verdict.snapshot_mac_diverged,
            "divergence must be reported with has_missing_remove={has_missing_remove}"
        );
    }
}

#[test]
fn test_mixed_set_and_remove_with_missing_remove() {
    // Test a realistic scenario: a patch with both SET and REMOVE mutations
    // where some REMOVEs target entries we don't have

    let master_key = [7u8; 32];
    let keys = expand_app_state_keys(&master_key);
    let key_id = b"test_key";

    let mut state = HashState::default();

    // First, add an entry that we DO have
    let index_mac_known = vec![1u8; 32];
    let value_blob_known = create_value_blob(&keys, key_id);
    let value_mac_known = value_blob_known[value_blob_known.len() - 32..].to_vec();

    let set_known = make_mutation(
        wa::syncd_mutation::SyncdOperation::SET,
        key_id,
        index_mac_known.clone(),
        Some(value_blob_known),
    );
    let (_, err) = state.update_hash(&[set_known], |_, _| Ok(None));
    assert!(err.is_ok());

    // Now process a batch with:
    // 1. A new SET
    // 2. A REMOVE for the known entry (should work)
    // 3. A REMOVE for an unknown entry (should trigger has_missing_remove)

    let index_mac_new = vec![3u8; 32];
    let value_blob_new = create_value_blob(&keys, key_id);

    let index_mac_unknown = vec![99u8; 32];

    let mutations = vec![
        make_mutation(
            wa::syncd_mutation::SyncdOperation::SET,
            key_id,
            index_mac_new,
            Some(value_blob_new),
        ),
        make_mutation(
            wa::syncd_mutation::SyncdOperation::REMOVE,
            key_id,
            index_mac_known.clone(),
            None,
        ),
        make_mutation(
            wa::syncd_mutation::SyncdOperation::REMOVE,
            key_id,
            index_mac_unknown,
            None,
        ),
    ];

    let (result, err) = state.update_hash(&mutations, |idx, _| {
        if idx == index_mac_known {
            Ok(Some(value_mac_known.clone()))
        } else {
            Ok(None)
        }
    });

    assert!(err.is_ok());
    assert!(
        result.has_missing_remove,
        "Should have has_missing_remove due to unknown REMOVE"
    );
}