use proptest::prelude::*;
use serde_json::{json, Value};
use gitclaw::canonicalize;
fn json_primitive() -> impl Strategy<Value = Value> {
prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::Bool),
(-1_000_000_000_i64..1_000_000_000_i64).prop_map(|n| json!(n)),
(-1_000_000_i32..1_000_000_i32)
.prop_map(|n| {
let f = f64::from(n) / 100.0;
json!(f)
}),
"[a-zA-Z0-9 _\\-\\.]{0,50}".prop_map(|s| json!(s)),
]
}
fn json_value() -> impl Strategy<Value = Value> {
json_primitive().prop_recursive(
3, 64, 10, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..5).prop_map(Value::Array),
prop::collection::btree_map("[a-zA-Z_][a-zA-Z0-9_]{0,10}", inner, 0..5)
.prop_map(|map| {
let obj: serde_json::Map<String, Value> = map.into_iter().collect();
Value::Object(obj)
}),
]
},
)
}
proptest! {
#[test]
fn test_jcs_canonicalization_round_trip(value in json_value()) {
let canonical1 = canonicalize(&value).expect("First canonicalization should succeed");
let parsed: Value = serde_json::from_str(&canonical1)
.expect("Canonical JSON should be valid JSON");
let canonical2 = canonicalize(&parsed).expect("Second canonicalization should succeed");
prop_assert_eq!(
&canonical1,
&canonical2,
"Round-trip failed:\n Original: {:?}\n First canonical: {}\n Parsed: {:?}\n Second canonical: {}",
value,
canonical1.clone(),
parsed,
canonical2.clone()
);
}
#[test]
fn test_keys_are_sorted(
obj in prop::collection::btree_map("[a-zA-Z_][a-zA-Z0-9_]{0,10}", json_primitive(), 0..10)
) {
let value: Value = {
let map: serde_json::Map<String, Value> = obj.into_iter().collect();
Value::Object(map)
};
let canonical = canonicalize(&value).expect("Canonicalization should succeed");
let parsed: Value = serde_json::from_str(&canonical).expect("Should parse");
if let Value::Object(map) = parsed {
let keys: Vec<&String> = map.keys().collect();
let mut sorted_keys = keys.clone();
sorted_keys.sort();
prop_assert_eq!(keys, sorted_keys, "Keys should be sorted");
}
}
#[test]
fn test_string_escaping_produces_valid_json(s in ".*") {
let value = json!(s);
let canonical = canonicalize(&value).expect("Canonicalization should succeed");
let parsed: Value = serde_json::from_str(&canonical)
.expect("Canonical string should be valid JSON");
if let Value::String(parsed_str) = parsed {
prop_assert_eq!(s, parsed_str, "String should round-trip correctly");
} else {
prop_assert!(false, "Parsed value should be a string");
}
}
}
#[test]
fn test_no_whitespace_between_tokens() {
let obj = json!({"a": 1, "b": [1, 2, 3], "c": {"nested": true}});
let canonical = canonicalize(&obj).expect("Canonicalization should succeed");
assert!(!canonical.contains(' '), "Should not contain spaces");
}
#[test]
fn test_negative_zero_becomes_zero() {
let val: Value = serde_json::from_str("-0.0").expect("Should parse");
let canonical = canonicalize(&val).expect("Canonicalization should succeed");
assert_eq!(canonical, "0", "-0.0 should become \"0\"");
}
#[test]
fn test_integers_have_no_decimal() {
assert_eq!(canonicalize(&json!(42)).unwrap(), "42");
assert_eq!(canonicalize(&json!(-100)).unwrap(), "-100");
assert_eq!(canonicalize(&json!(0)).unwrap(), "0");
}
use gitclaw::{Ed25519Signer, EcdsaSigner, Signer};
proptest! {
#[test]
fn test_ed25519_key_loading_round_trip(message in prop::collection::vec(any::<u8>(), 1..1000)) {
let (signer, public_key) = Ed25519Signer::generate();
let pem = signer.private_key_pem();
let loaded_signer = Ed25519Signer::from_pem(&pem)
.expect("Should load from PEM");
let signature = signer.sign(&message).expect("Should sign");
prop_assert!(
loaded_signer.verify(&signature, &message),
"Signature verification failed after PEM round-trip"
);
let signature2 = loaded_signer.sign(&message).expect("Should sign");
prop_assert!(
signer.verify(&signature2, &message),
"Cross-verification failed after PEM round-trip"
);
prop_assert_eq!(
signer.public_key(),
loaded_signer.public_key(),
"Public keys don't match after PEM round-trip"
);
prop_assert!(
public_key.starts_with("ed25519:"),
"Public key should have ed25519: prefix"
);
}
#[test]
fn test_ecdsa_key_loading_round_trip(message in prop::collection::vec(any::<u8>(), 1..1000)) {
let (signer, public_key) = EcdsaSigner::generate();
let pem = signer.private_key_pem();
let loaded_signer = EcdsaSigner::from_pem(&pem)
.expect("Should load from PEM");
let signature = signer.sign(&message).expect("Should sign");
prop_assert!(
loaded_signer.verify(&signature, &message),
"Signature verification failed after PEM round-trip"
);
let signature2 = loaded_signer.sign(&message).expect("Should sign");
prop_assert!(
signer.verify(&signature2, &message),
"Cross-verification failed after PEM round-trip"
);
prop_assert_eq!(
signer.public_key(),
loaded_signer.public_key(),
"Public keys don't match after PEM round-trip"
);
prop_assert!(
public_key.starts_with("ecdsa:"),
"Public key should have ecdsa: prefix"
);
}
#[test]
fn test_ed25519_from_bytes_round_trip(seed in prop::array::uniform32(any::<u8>())) {
let signer = Ed25519Signer::from_bytes(&seed)
.expect("Should create from bytes");
let message = b"test message";
let signature = signer.sign(message).expect("Should sign");
prop_assert!(
signer.verify(&signature, message),
"Signature verification failed"
);
let signer2 = Ed25519Signer::from_bytes(&seed)
.expect("Should create from bytes");
prop_assert_eq!(
signer.public_key(),
signer2.public_key(),
"Same seed should produce same public key"
);
}
}
#[test]
fn test_ed25519_signature_length() {
let (signer, _) = Ed25519Signer::generate();
for msg in [b"".as_slice(), b"x".as_slice(), &[b'x'; 1000]] {
let sig = signer.sign(msg).expect("Should sign");
assert_eq!(sig.len(), 64, "Ed25519 signature should be 64 bytes");
}
}
#[test]
fn test_ed25519_signature_is_deterministic() {
let (signer, _) = Ed25519Signer::generate();
let message = b"test message";
let sig1 = signer.sign(message).expect("Should sign");
let sig2 = signer.sign(message).expect("Should sign");
assert_eq!(sig1, sig2, "Ed25519 signatures should be deterministic");
}
#[test]
fn test_ecdsa_signature_is_der_encoded() {
let (signer, _) = EcdsaSigner::generate();
for i in 0..10 {
let sig = signer.sign(format!("message {i}").as_bytes()).expect("Should sign");
assert!(
(68..=72).contains(&sig.len()),
"Unexpected signature length: {}",
sig.len()
);
}
}
use gitclaw::{EnvelopeBuilder, SignatureEnvelope, sign_envelope, compute_nonce_hash};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use std::collections::{HashMap, HashSet};
proptest! {
#[test]
fn test_signature_generation_produces_valid_signatures(
agent_id in "[a-zA-Z0-9_-]{1,50}",
action in "[a-zA-Z_][a-zA-Z0-9_]{0,30}",
body_keys in prop::collection::vec("[a-zA-Z_][a-zA-Z0-9_]{0,20}", 0..5),
body_values in prop::collection::vec("[a-zA-Z0-9 _-]{0,50}", 0..5)
) {
let (signer, _) = Ed25519Signer::generate();
let mut body: HashMap<String, serde_json::Value> = HashMap::new();
for (key, value) in body_keys.iter().zip(body_values.iter()) {
body.insert(key.clone(), serde_json::json!(value));
}
let builder = EnvelopeBuilder::new(agent_id.clone());
let envelope = builder.build(&action, body);
let signature = sign_envelope(&envelope, &signer)
.expect("Signing should succeed");
let decoded = BASE64.decode(&signature)
.expect("Signature should be valid base64");
prop_assert_eq!(
decoded.len(),
64,
"Ed25519 signature should be 64 bytes, got {}",
decoded.len()
);
let message_hash = gitclaw::get_message_hash(&envelope)
.expect("Hashing should succeed");
prop_assert!(
signer.verify(&decoded, &message_hash),
"Signature verification failed"
);
let envelope_value = envelope.to_value();
prop_assert!(envelope_value.get("agentId").is_some(), "Missing agentId");
prop_assert!(envelope_value.get("action").is_some(), "Missing action");
prop_assert!(envelope_value.get("timestamp").is_some(), "Missing timestamp");
prop_assert!(envelope_value.get("nonce").is_some(), "Missing nonce");
prop_assert!(envelope_value.get("body").is_some(), "Missing body");
prop_assert_eq!(
envelope_value["agentId"].as_str(),
Some(agent_id.as_str()),
"agentId mismatch"
);
prop_assert_eq!(
envelope_value["action"].as_str(),
Some(action.as_str()),
"action mismatch"
);
}
#[test]
fn test_retry_generates_new_nonces(
agent_id in "[a-zA-Z0-9_-]{1,50}",
action in "[a-zA-Z_][a-zA-Z0-9_]{0,30}",
num_retries in 2..10usize
) {
let builder = EnvelopeBuilder::new(agent_id);
let mut nonces: HashSet<String> = HashSet::new();
for _ in 0..num_retries {
let envelope = builder.build_empty(&action);
prop_assert!(
nonces.insert(envelope.nonce.clone()),
"Duplicate nonce detected: {}",
envelope.nonce
);
prop_assert!(
uuid::Uuid::parse_str(&envelope.nonce).is_ok(),
"Nonce is not a valid UUID: {}",
envelope.nonce
);
}
prop_assert_eq!(
nonces.len(),
num_retries,
"Expected {} unique nonces, got {}",
num_retries,
nonces.len()
);
}
#[test]
fn test_nonce_hash_computation(
agent_id in "[a-zA-Z0-9_-]{1,50}",
nonce in "[a-zA-Z0-9-]{36}"
) {
let hash = compute_nonce_hash(&agent_id, &nonce);
prop_assert_eq!(
hash.len(),
64,
"Nonce hash should be 64 hex chars, got {}",
hash.len()
);
prop_assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"Nonce hash should be valid hex: {}",
hash
);
use sha2::{Sha256, Digest};
let data = format!("{agent_id}:{nonce}");
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
let expected = hex::encode(hasher.finalize());
prop_assert_eq!(
hash,
expected,
"Nonce hash mismatch"
);
}
#[test]
fn test_signature_generation_is_deterministic(
agent_id in "[a-zA-Z0-9_-]{1,50}",
action in "[a-zA-Z_][a-zA-Z0-9_]{0,30}"
) {
let (signer, _) = Ed25519Signer::generate();
let envelope = SignatureEnvelope::new(
agent_id,
action,
chrono::DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.expect("valid timestamp")
.with_timezone(&chrono::Utc),
"fixed-nonce-12345".to_string(),
HashMap::new(),
);
let sig1 = sign_envelope(&envelope, &signer).expect("Signing should succeed");
let sig2 = sign_envelope(&envelope, &signer).expect("Signing should succeed");
prop_assert_eq!(
sig1,
sig2,
"Signatures should be deterministic for the same envelope"
);
}
}
#[test]
fn test_envelope_contains_all_required_fields() {
let builder = EnvelopeBuilder::new("test-agent".to_string());
let envelope = builder.build_empty("test_action");
let value = envelope.to_value();
assert!(value.get("agentId").is_some());
assert!(value.get("action").is_some());
assert!(value.get("timestamp").is_some());
assert!(value.get("nonce").is_some());
assert!(value.get("body").is_some());
assert!(value["agentId"].is_string());
assert!(value["action"].is_string());
assert!(value["timestamp"].is_string());
assert!(value["nonce"].is_string());
assert!(value["body"].is_object());
}
#[test]
fn test_nonce_is_uuid_v4() {
let builder = EnvelopeBuilder::new("test-agent".to_string());
for _ in 0..10 {
let envelope = builder.build_empty("test_action");
let uuid = uuid::Uuid::parse_str(&envelope.nonce)
.expect("Nonce should be a valid UUID");
assert_eq!(uuid.get_version_num(), 4, "Nonce should be UUID v4");
}
}
#[test]
fn test_timestamp_format_is_iso8601() {
let builder = EnvelopeBuilder::new("test-agent".to_string());
let envelope = builder.build_empty("test_action");
let timestamp = envelope.format_timestamp();
assert!(timestamp.ends_with('Z'), "Timestamp should end with Z");
chrono::DateTime::parse_from_rfc3339(×tamp)
.expect("Timestamp should be valid ISO 8601");
}