nahui 0.2.0

Authenticated encryption (ChaCha20-Poly1305) and BLAKE3 content hashing for document attestation.
Documentation
//! Browser-side tests, run via `wasm-pack test --headless --chrome` (or --firefox).
//!
//! These tests simulate the realistic browser flow:
//!
//! 1. User selects a file in the browser.
//! 2. JS reads it with `FileReader.readAsArrayBuffer()` → `Uint8Array`.
//! 3. The bytes are passed into wasm as `Vec<u8>`.
//! 4. `hash_and_encrypt_stream` produces the encrypted bytes + a BLAKE3 hash.
//! 5. The hash goes into a signed Nostr attestation note.
//! 6. The encrypted bytes are uploaded to S3.

use std::io::Cursor;

use wasm_bindgen_test::*;

use crate::crypto::{self, Error, STREAM_NONCE_LEN, TAG_LEN, VERSION, Vault};

wasm_bindgen_test_configure!(run_in_browser);

const AAD: &[u8] = b"file:upload";

fn vault() -> Vault {
    Vault::new([0x42u8; 32])
}

/// Fake file bytes — stand-in for what JS hands us after FileReader.
fn fake_file(size: usize) -> Vec<u8> {
    (0..size).map(|i| (i & 0xFF) as u8).collect()
}

// ---------------------------------------------------------------------------
// Core browser flow
// ---------------------------------------------------------------------------

/// Full happy-path: hash+encrypt → decrypt → compare.
#[wasm_bindgen_test]
fn browser_hash_and_encrypt_roundtrip() {
    let file_bytes = fake_file(256 * 1024); // 256 KB
    let v = vault();

    let mut encrypted = Vec::new();
    let hash = v
        .hash_and_encrypt_stream(&mut Cursor::new(&file_bytes), &mut encrypted, AAD)
        .unwrap();

    // The hash is what goes into the Nostr note.
    assert_eq!(hash.to_hex().len(), 64);

    let mut decrypted = Vec::new();
    v.decrypt_stream(&mut Cursor::new(encrypted), &mut decrypted, AAD)
        .unwrap();
    assert_eq!(file_bytes, decrypted);
}

/// The hash returned by `hash_and_encrypt_stream` must equal the hash of
/// the original plaintext, not the ciphertext — the attestation is over
/// the content, not the encrypted blob.
#[wasm_bindgen_test]
fn browser_hash_is_of_plaintext_not_ciphertext() {
    let file_bytes = fake_file(128 * 1024);
    let v = vault();

    let mut encrypted = Vec::new();
    let got_hash = v
        .hash_and_encrypt_stream(&mut Cursor::new(&file_bytes), &mut encrypted, AAD)
        .unwrap();

    assert_eq!(got_hash, crypto::hash_bytes(&file_bytes));

    // Sanity: hash of the on-the-wire bytes is different from hash of plaintext.
    assert_ne!(got_hash, crypto::hash_bytes(&encrypted));
}

// ---------------------------------------------------------------------------
// Entropy: verify getrandom's "js" feature routes through window.crypto
// ---------------------------------------------------------------------------

#[wasm_bindgen_test]
fn browser_nonces_are_random() {
    let v = vault();
    let msg = b"same plaintext".as_slice();

    let mut ct1 = Vec::new();
    let mut ct2 = Vec::new();
    v.encrypt_stream(&mut Cursor::new(msg), &mut ct1, AAD)
        .unwrap();
    v.encrypt_stream(&mut Cursor::new(msg), &mut ct2, AAD)
        .unwrap();

    // Different header nonces → different ciphertexts even for identical plaintext.
    let header_range = 1..1 + STREAM_NONCE_LEN;
    assert_ne!(&ct1[header_range.clone()], &ct2[header_range]);
    assert_ne!(ct1, ct2);
}

// ---------------------------------------------------------------------------
// Tamper / integrity / context-binding
// ---------------------------------------------------------------------------

#[wasm_bindgen_test]
fn browser_tampered_ciphertext_rejected() {
    let v = vault();
    let mut ct = Vec::new();
    v.encrypt_stream(&mut Cursor::new(b"document".as_slice()), &mut ct, AAD)
        .unwrap();
    // Flip a byte inside the first chunk's ciphertext.
    ct[1 + STREAM_NONCE_LEN] ^= 0xff;

    let mut pt = Vec::new();
    assert!(matches!(
        v.decrypt_stream(&mut Cursor::new(ct), &mut pt, AAD),
        Err(Error::AuthenticationFailed)
    ));
}

#[wasm_bindgen_test]
fn browser_wrong_key_rejected() {
    let v = vault();
    let mut ct = Vec::new();
    v.encrypt_stream(&mut Cursor::new(b"document".as_slice()), &mut ct, AAD)
        .unwrap();

    let bad = Vault::new([0x00u8; 32]);
    let mut pt = Vec::new();
    assert!(matches!(
        bad.decrypt_stream(&mut Cursor::new(ct), &mut pt, AAD),
        Err(Error::AuthenticationFailed)
    ));
}

#[wasm_bindgen_test]
fn browser_wrong_aad_rejected() {
    let v = vault();
    let mut ct = Vec::new();
    v.encrypt_stream(&mut Cursor::new(b"document".as_slice()), &mut ct, b"file:A")
        .unwrap();

    let mut pt = Vec::new();
    assert!(matches!(
        v.decrypt_stream(&mut Cursor::new(ct), &mut pt, b"file:B"),
        Err(Error::AuthenticationFailed)
    ));
}

// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------

#[wasm_bindgen_test]
fn browser_empty_file_roundtrip() {
    let v = vault();
    let mut ct = Vec::new();
    let hash = v
        .hash_and_encrypt_stream(&mut Cursor::new(b"".as_slice()), &mut ct, AAD)
        .unwrap();

    // Empty file still emits version + header_nonce + a single tag.
    assert_eq!(ct.len(), 1 + STREAM_NONCE_LEN + TAG_LEN);
    assert_eq!(ct[0], VERSION);

    let mut pt = Vec::new();
    v.decrypt_stream(&mut Cursor::new(ct), &mut pt, AAD)
        .unwrap();
    assert!(pt.is_empty());
    assert_eq!(hash.to_hex().len(), 64);
}

#[wasm_bindgen_test]
fn browser_large_file_roundtrip() {
    let file_bytes = fake_file(3 * 1024 * 1024); // 3 MB
    let v = vault();

    let mut ct = Vec::new();
    let hash = v
        .hash_and_encrypt_stream(&mut Cursor::new(&file_bytes), &mut ct, AAD)
        .unwrap();

    let mut pt = Vec::new();
    v.decrypt_stream(&mut Cursor::new(ct), &mut pt, AAD)
        .unwrap();

    assert_eq!(file_bytes, pt);
    assert_eq!(hash, crypto::hash_bytes(&file_bytes));
}