From f0f33e0934edbf565461eca9bee213fe8ef0bb30 Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Mon, 19 Jan 2026 08:49:30 +0700 Subject: [PATCH] Implement sender client state machine with comprehensive tests - Add SenderClient state machine with states: Idle, Ready, Composing, Encrypting, Submitted, Error - Implement all state transitions according to specification - Use sodiumoxide for sealed box encryption - Add validation for sender name (max 200 chars) and message body (max 10,000 chars) - Implement complete test suite covering: - State transitions and invalid transitions - Input validation (empty, max length, over max) - Encryption (ciphertext randomness, different messages) - Unicode and special characters support - Multiple messages in sequence - Error handling and recovery --- .gitignore | 1 + Cargo.lock | 370 ++++++++++++++++++++++++ Cargo.toml | 13 + README.md | 14 + src/lib.rs | 3 + src/sender.rs | 761 ++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1162 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/lib.rs create mode 100644 src/sender.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cae99c0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,370 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cc" +version = "1.2.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libsodium-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd" +dependencies = [ + "cc", + "libc", + "pkg-config", + "walkdir", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "messagebox-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sodiumoxide", + "thiserror", + "uuid", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "sodiumoxide" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028" +dependencies = [ + "ed25519", + "libc", + "libsodium-sys", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zmij" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bee03fb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "messagebox-protocol" +version = "0.1.0" +edition = "2021" + +[dependencies] +sodiumoxide = "0.2" +uuid = { version = "1.6", features = ["v4", "serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" + +[dev-dependencies] diff --git a/README.md b/README.md new file mode 100644 index 0000000..caf0d99 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +## About +A simple little protocol for a "message box" where people can send me messages that are sealed "on site" in their local client (most likely +their web browser). Read `SPECIFICATION.md` for more details. + +## Purpose +This is intended to test a workflow using LLM pair design and programming. I've found LLMs to be great at dumping code that is annoying +to read and understand, but mostly works. I would like to design systems that are pleasant and easy to understand, with the essential +components displayed to the human in a way that is abstacted away from noisy implementation details, which are handled by the LLM. + +### Goals +- [x] Complete specification in informal markdown +- [ ] Implement concretely in code +- [ ] Apply standard code tests +- [ ] Complete specification in formal language (P, Quint, etc.) diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9ccf3cb --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod sender; + +pub use sender::{SenderClient, SenderState, SenderError}; diff --git a/src/sender.rs b/src/sender.rs new file mode 100644 index 0000000..ae2ec92 --- /dev/null +++ b/src/sender.rs @@ -0,0 +1,761 @@ +use serde::{Deserialize, Serialize}; +use sodiumoxide::crypto::box_::{self, PublicKey as SodiumPublicKey}; +use sodiumoxide::crypto::sealedbox; +use thiserror::Error; +use uuid::Uuid; + +/// Errors that can occur in the sender client state machine +#[derive(Error, Debug, PartialEq)] +pub enum SenderError { + #[error("No public key loaded")] + NoPublicKey, + + #[error("No plaintext message available")] + NoPlaintext, + + #[error("No sealed message available")] + NoSealedMessage, + + #[error("Invalid sender name: {0}")] + InvalidSenderName(String), + + #[error("Invalid message body: {0}")] + InvalidMessageBody(String), + + #[error("Encryption failed")] + EncryptionFailed, + + #[error("Invalid state transition from {from} to {to}")] + InvalidStateTransition { from: String, to: String }, +} + +/// States of the sender client state machine +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SenderState { + Idle, + Ready, + Composing, + Encrypting, + Submitted, + Error, +} + +impl std::fmt::Display for SenderState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SenderState::Idle => write!(f, "Idle"), + SenderState::Ready => write!(f, "Ready"), + SenderState::Composing => write!(f, "Composing"), + SenderState::Encrypting => write!(f, "Encrypting"), + SenderState::Submitted => write!(f, "Submitted"), + SenderState::Error => write!(f, "Error"), + } + } +} + +/// Public key information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicKey { + pub key_id: String, + pub public_key: [u8; 32], +} + +/// Plaintext message before encryption +#[derive(Debug, Clone)] +struct PlaintextMessage { + sender_name: String, + message_body: String, +} + +/// Sealed message ready for transmission +#[derive(Debug, Clone)] +struct SealedMessage { + sender_name: String, + key_id: String, + sealed_box: Vec, +} + +/// Message record to be sent to the message store +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageRecord { + pub message_id: Uuid, + pub sender_name: String, + pub created_at: i64, // Unix timestamp + pub key_id: String, + pub sealed_box: Vec, +} + +/// Sender client state machine +pub struct SenderClient { + state: SenderState, + public_key: Option, + plaintext: Option, + sealed_message: Option, +} + +impl SenderClient { + /// Create a new sender client in IDLE state + pub fn new() -> Self { + Self { + state: SenderState::Idle, + public_key: None, + plaintext: None, + sealed_message: None, + } + } + + /// Get the current state + pub fn state(&self) -> SenderState { + self.state + } + + /// Transition: IDLE → READY + /// Load the public key that will be used for encryption + pub fn load_public_key(&mut self, public_key: PublicKey) -> Result<(), SenderError> { + if self.state != SenderState::Idle { + return Err(SenderError::InvalidStateTransition { + from: self.state.to_string(), + to: "Ready".to_string(), + }); + } + + self.public_key = Some(public_key); + self.state = SenderState::Ready; + Ok(()) + } + + /// Transition: READY → COMPOSING + /// Compose a message with sender name and message body + pub fn compose_message( + &mut self, + sender_name: String, + message_body: String, + ) -> Result<(), SenderError> { + if self.state != SenderState::Ready { + return Err(SenderError::InvalidStateTransition { + from: self.state.to_string(), + to: "Composing".to_string(), + }); + } + + // Validate sender name + if sender_name.is_empty() { + return Err(SenderError::InvalidSenderName( + "Sender name cannot be empty".to_string(), + )); + } + if sender_name.len() > 200 { + return Err(SenderError::InvalidSenderName( + "Sender name exceeds 200 characters".to_string(), + )); + } + + // Validate message body + if message_body.is_empty() { + return Err(SenderError::InvalidMessageBody( + "Message body cannot be empty".to_string(), + )); + } + if message_body.len() > 10_000 { + return Err(SenderError::InvalidMessageBody( + "Message body exceeds 10,000 characters".to_string(), + )); + } + + self.plaintext = Some(PlaintextMessage { + sender_name, + message_body, + }); + self.state = SenderState::Composing; + Ok(()) + } + + /// Transition: COMPOSING → ENCRYPTING + /// Seal the message using the loaded public key + pub fn seal_message(&mut self) -> Result<(), SenderError> { + if self.state != SenderState::Composing { + return Err(SenderError::InvalidStateTransition { + from: self.state.to_string(), + to: "Encrypting".to_string(), + }); + } + + let public_key = self.public_key.as_ref().ok_or(SenderError::NoPublicKey)?; + let plaintext = self.plaintext.take().ok_or(SenderError::NoPlaintext)?; + + // Convert public key bytes to sodiumoxide type + let pk = SodiumPublicKey::from_slice(&public_key.public_key) + .ok_or(SenderError::EncryptionFailed)?; + + // Encrypt the message body + let sealed_box = sealedbox::seal(plaintext.message_body.as_bytes(), &pk); + + self.sealed_message = Some(SealedMessage { + sender_name: plaintext.sender_name, + key_id: public_key.key_id.clone(), + sealed_box, + }); + + // Plaintext is already taken and will be dropped + self.state = SenderState::Encrypting; + Ok(()) + } + + /// Transition: ENCRYPTING → SUBMITTED + /// Create a message record ready for transmission to the store + pub fn submit(&mut self) -> Result { + if self.state != SenderState::Encrypting { + return Err(SenderError::InvalidStateTransition { + from: self.state.to_string(), + to: "Submitted".to_string(), + }); + } + + let sealed = self + .sealed_message + .take() + .ok_or(SenderError::NoSealedMessage)?; + + let record = MessageRecord { + message_id: Uuid::new_v4(), + sender_name: sealed.sender_name, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + key_id: sealed.key_id, + sealed_box: sealed.sealed_box, + }; + + self.state = SenderState::Submitted; + Ok(record) + } + + /// Transition: SUBMITTED → IDLE + /// Reset the state machine for the next message + pub fn reset(&mut self) { + if self.state != SenderState::Submitted { + // Allow reset from any state for error recovery + } + + self.plaintext = None; + self.sealed_message = None; + self.state = SenderState::Idle; + } + + /// Transition: Any State → ERROR + /// Handle errors by transitioning to error state + pub fn error(&mut self) { + self.state = SenderState::Error; + } + + /// Check if ready to compose (has public key loaded) + pub fn is_ready(&self) -> bool { + self.state == SenderState::Ready && self.public_key.is_some() + } +} + +impl Default for SenderClient { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_public_key() -> super::PublicKey { + // Initialize sodiumoxide + sodiumoxide::init().unwrap(); + + // Generate a real keypair for testing + let (pk, _sk) = box_::gen_keypair(); + + super::PublicKey { + key_id: "test-key-1".to_string(), + public_key: pk.0, + } + } + + #[test] + fn test_new_sender_starts_idle() { + let sender = SenderClient::new(); + assert_eq!(sender.state(), SenderState::Idle); + } + + #[test] + fn test_load_public_key_transitions_to_ready() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + let result = sender.load_public_key(pk); + assert!(result.is_ok()); + assert_eq!(sender.state(), SenderState::Ready); + assert!(sender.is_ready()); + } + + #[test] + fn test_cannot_load_key_when_not_idle() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk.clone()).unwrap(); + + let result = sender.load_public_key(pk); + assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. }))); + } + + #[test] + fn test_compose_message_transitions_to_composing() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let result = sender.compose_message( + "Alice".to_string(), + "Hello, Bob!".to_string(), + ); + assert!(result.is_ok()); + assert_eq!(sender.state(), SenderState::Composing); + } + + #[test] + fn test_compose_rejects_empty_sender_name() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let result = sender.compose_message( + "".to_string(), + "Hello!".to_string(), + ); + assert!(matches!(result, Err(SenderError::InvalidSenderName(_)))); + } + + #[test] + fn test_compose_rejects_long_sender_name() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let long_name = "a".repeat(201); + let result = sender.compose_message( + long_name, + "Hello!".to_string(), + ); + assert!(matches!(result, Err(SenderError::InvalidSenderName(_)))); + } + + #[test] + fn test_compose_rejects_empty_message_body() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let result = sender.compose_message( + "Alice".to_string(), + "".to_string(), + ); + assert!(matches!(result, Err(SenderError::InvalidMessageBody(_)))); + } + + #[test] + fn test_compose_rejects_long_message_body() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let long_message = "a".repeat(10_001); + let result = sender.compose_message( + "Alice".to_string(), + long_message, + ); + assert!(matches!(result, Err(SenderError::InvalidMessageBody(_)))); + } + + #[test] + fn test_seal_message_transitions_to_encrypting() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + + let result = sender.seal_message(); + assert!(result.is_ok()); + assert_eq!(sender.state(), SenderState::Encrypting); + } + + #[test] + fn test_seal_destroys_plaintext() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + + sender.seal_message().unwrap(); + + // Plaintext should be None after sealing + assert!(sender.plaintext.is_none()); + } + + #[test] + fn test_submit_creates_message_record() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk.clone()).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.seal_message().unwrap(); + + let result = sender.submit(); + assert!(result.is_ok()); + + let record = result.unwrap(); + assert_eq!(record.sender_name, "Alice"); + assert_eq!(record.key_id, pk.key_id); + assert!(!record.sealed_box.is_empty()); + assert_eq!(sender.state(), SenderState::Submitted); + } + + #[test] + fn test_reset_transitions_to_idle() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.seal_message().unwrap(); + sender.submit().unwrap(); + + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + } + + #[test] + fn test_full_happy_path() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + // IDLE → READY + sender.load_public_key(pk.clone()).unwrap(); + assert_eq!(sender.state(), SenderState::Ready); + + // READY → COMPOSING + sender.compose_message("Alice".to_string(), "Hello, Bob!".to_string()).unwrap(); + assert_eq!(sender.state(), SenderState::Composing); + + // COMPOSING → ENCRYPTING + sender.seal_message().unwrap(); + assert_eq!(sender.state(), SenderState::Encrypting); + + // ENCRYPTING → SUBMITTED + let record = sender.submit().unwrap(); + assert_eq!(sender.state(), SenderState::Submitted); + assert_eq!(record.sender_name, "Alice"); + assert_eq!(record.key_id, pk.key_id); + + // SUBMITTED → IDLE + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + } + + #[test] + fn test_cannot_skip_states() { + let mut sender = SenderClient::new(); + + // Cannot compose without loading key + let result = sender.compose_message("Alice".to_string(), "Hello!".to_string()); + assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. }))); + + // Cannot seal without composing + let pk = create_test_public_key(); + sender.load_public_key(pk).unwrap(); + let result = sender.seal_message(); + assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. }))); + } + + #[test] + fn test_cannot_submit_without_sealing() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + + // Try to submit without sealing + let result = sender.submit(); + assert!(matches!(result, Err(SenderError::InvalidStateTransition { .. }))); + } + + #[test] + fn test_message_at_max_length() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + // Exactly 10,000 characters should be allowed + let max_message = "a".repeat(10_000); + let result = sender.compose_message("Alice".to_string(), max_message); + assert!(result.is_ok()); + } + + #[test] + fn test_sender_name_at_max_length() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + // Exactly 200 characters should be allowed + let max_name = "a".repeat(200); + let result = sender.compose_message(max_name, "Hello!".to_string()); + assert!(result.is_ok()); + } + + #[test] + fn test_reset_from_error_state() { + let mut sender = SenderClient::new(); + + sender.error(); + assert_eq!(sender.state(), SenderState::Error); + + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + } + + #[test] + fn test_reset_from_intermediate_states() { + // Test reset from READY state + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + sender.load_public_key(pk.clone()).unwrap(); + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + + // Test reset from COMPOSING state + let mut sender = SenderClient::new(); + sender.load_public_key(pk.clone()).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + + // Test reset from ENCRYPTING state + let mut sender = SenderClient::new(); + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.seal_message().unwrap(); + sender.reset(); + assert_eq!(sender.state(), SenderState::Idle); + } + + #[test] + fn test_multiple_messages_in_sequence() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + // First message + sender.load_public_key(pk.clone()).unwrap(); + sender.compose_message("Alice".to_string(), "First message".to_string()).unwrap(); + sender.seal_message().unwrap(); + let record1 = sender.submit().unwrap(); + sender.reset(); + + // Second message + sender.load_public_key(pk.clone()).unwrap(); + sender.compose_message("Bob".to_string(), "Second message".to_string()).unwrap(); + sender.seal_message().unwrap(); + let record2 = sender.submit().unwrap(); + sender.reset(); + + // Messages should have different IDs + assert_ne!(record1.message_id, record2.message_id); + assert_eq!(record1.sender_name, "Alice"); + assert_eq!(record2.sender_name, "Bob"); + } + + #[test] + fn test_sealed_box_is_not_empty() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Secret message".to_string()).unwrap(); + sender.seal_message().unwrap(); + let record = sender.submit().unwrap(); + + // Sealed box should contain encrypted data + assert!(!record.sealed_box.is_empty()); + // Sealed box should be longer than the plaintext due to crypto overhead + assert!(record.sealed_box.len() > "Secret message".len()); + } + + #[test] + fn test_message_record_has_valid_timestamp() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.seal_message().unwrap(); + let record = sender.submit().unwrap(); + + let after = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Timestamp should be between before and after + assert!(record.created_at >= before); + assert!(record.created_at <= after); + } + + #[test] + fn test_message_record_preserves_key_id() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + let expected_key_id = pk.key_id.clone(); + + sender.load_public_key(pk).unwrap(); + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + sender.seal_message().unwrap(); + let record = sender.submit().unwrap(); + + assert_eq!(record.key_id, expected_key_id); + } + + #[test] + fn test_different_messages_produce_different_ciphertext() { + let pk = create_test_public_key(); + + // First message + let mut sender1 = SenderClient::new(); + sender1.load_public_key(pk.clone()).unwrap(); + sender1.compose_message("Alice".to_string(), "Message A".to_string()).unwrap(); + sender1.seal_message().unwrap(); + let record1 = sender1.submit().unwrap(); + + // Second message (different content) + let mut sender2 = SenderClient::new(); + sender2.load_public_key(pk).unwrap(); + sender2.compose_message("Alice".to_string(), "Message B".to_string()).unwrap(); + sender2.seal_message().unwrap(); + let record2 = sender2.submit().unwrap(); + + // Ciphertexts should be different + assert_ne!(record1.sealed_box, record2.sealed_box); + } + + #[test] + fn test_same_message_produces_different_ciphertext() { + // Due to sealed box randomness, same plaintext should produce different ciphertext + let pk = create_test_public_key(); + + // First encryption + let mut sender1 = SenderClient::new(); + sender1.load_public_key(pk.clone()).unwrap(); + sender1.compose_message("Alice".to_string(), "Same message".to_string()).unwrap(); + sender1.seal_message().unwrap(); + let record1 = sender1.submit().unwrap(); + + // Second encryption of same message + let mut sender2 = SenderClient::new(); + sender2.load_public_key(pk).unwrap(); + sender2.compose_message("Alice".to_string(), "Same message".to_string()).unwrap(); + sender2.seal_message().unwrap(); + let record2 = sender2.submit().unwrap(); + + // Ciphertexts should be different (sealed box uses randomness) + assert_ne!(record1.sealed_box, record2.sealed_box); + } + + #[test] + fn test_unicode_in_sender_name() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let result = sender.compose_message( + "Alice 🎉 Müller".to_string(), + "Hello!".to_string(), + ); + assert!(result.is_ok()); + } + + #[test] + fn test_unicode_in_message_body() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let result = sender.compose_message( + "Alice".to_string(), + "Hello 世界! 🌍".to_string(), + ); + assert!(result.is_ok()); + } + + #[test] + fn test_newlines_in_message_body() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let multiline_message = "Line 1\nLine 2\nLine 3"; + let result = sender.compose_message( + "Alice".to_string(), + multiline_message.to_string(), + ); + assert!(result.is_ok()); + } + + #[test] + fn test_special_characters_in_message() { + let mut sender = SenderClient::new(); + let pk = create_test_public_key(); + + sender.load_public_key(pk).unwrap(); + + let special_message = "Special: !@#$%^&*()[]{}|\\;':\"<>?,./`~"; + let result = sender.compose_message( + "Alice".to_string(), + special_message.to_string(), + ); + assert!(result.is_ok()); + } + + #[test] + fn test_is_ready_only_when_key_loaded() { + let mut sender = SenderClient::new(); + assert!(!sender.is_ready()); + + let pk = create_test_public_key(); + sender.load_public_key(pk).unwrap(); + assert!(sender.is_ready()); + + sender.compose_message("Alice".to_string(), "Hello!".to_string()).unwrap(); + assert!(!sender.is_ready()); // Not ready in COMPOSING state + } + + #[test] + fn test_default_creates_idle_sender() { + let sender = SenderClient::default(); + assert_eq!(sender.state(), SenderState::Idle); + } +}