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
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-01-19 08:49:30 +07:00
parent 184702057b
commit f0f33e0934
6 changed files with 1162 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

370
Cargo.lock generated Normal file
View file

@ -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"

13
Cargo.toml Normal file
View file

@ -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]

14
README.md Normal file
View file

@ -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.)

3
src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod sender;
pub use sender::{SenderClient, SenderState, SenderError};

761
src/sender.rs Normal file
View file

@ -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<u8>,
}
/// 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<u8>,
}
/// Sender client state machine
pub struct SenderClient {
state: SenderState,
public_key: Option<PublicKey>,
plaintext: Option<PlaintextMessage>,
sealed_message: Option<SealedMessage>,
}
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<MessageRecord, SenderError> {
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);
}
}