mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
fix(tests): replace hardcoded /tmp paths with tempdir + add 300 unit tests (#659)
* test: add unit tests across 20 modules for coverage push Add 300+ unit tests covering config, context, evaluation, extensions, LLM, secrets, tools/builder, and tools/mcp modules. All tests are pure unit tests (no mocks) exercising serde roundtrips, edge cases, error paths, and business logic. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(tests): replace hardcoded /tmp paths with tempfile::tempdir The e2e_metrics_test::test_metrics_collected_from_tool_trace test was failing because setup_test_dir() created /tmp/ironclaw_metrics_test but the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch). Added LlmTrace::replace_paths() to substitute fixture paths at runtime, then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no debris on disk. Regression test: test_metrics_collected_from_tool_trace now passes consistently regardless of prior /tmp state. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8fbb782090
commit
cf96a3253c
@@ -266,4 +266,108 @@ mod tests {
|
||||
let s2 = SecretsCrypto::generate_salt();
|
||||
assert_ne!(s1, s2, "two generated salts should not be identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_truncated_ciphertext() {
|
||||
let crypto = test_crypto();
|
||||
// Too short: less than NONCE_SIZE + TAG_SIZE (12 + 16 = 28)
|
||||
let short = vec![0u8; 10];
|
||||
let salt = SecretsCrypto::generate_salt();
|
||||
let result = crypto.decrypt(&short, &salt);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
crate::secrets::types::SecretError::DecryptionFailed(msg) => {
|
||||
assert!(msg.contains("too short"));
|
||||
}
|
||||
other => panic!("expected DecryptionFailed, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_master_keys_different_ciphertext() {
|
||||
let key_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
let key_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
let crypto_a = SecretsCrypto::new(SecretString::from(key_a.to_string())).unwrap();
|
||||
let crypto_b = SecretsCrypto::new(SecretString::from(key_b.to_string())).unwrap();
|
||||
|
||||
let plaintext = b"shared_secret";
|
||||
let (enc_a, salt_a) = crypto_a.encrypt(plaintext).unwrap();
|
||||
let (enc_b, salt_b) = crypto_b.encrypt(plaintext).unwrap();
|
||||
|
||||
// Each decrypts its own ciphertext
|
||||
let dec_a = crypto_a.decrypt(&enc_a, &salt_a).unwrap();
|
||||
let dec_b = crypto_b.decrypt(&enc_b, &salt_b).unwrap();
|
||||
assert_eq!(dec_a.expose(), "shared_secret");
|
||||
assert_eq!(dec_b.expose(), "shared_secret");
|
||||
|
||||
// Cross-decryption fails
|
||||
assert!(crypto_a.decrypt(&enc_b, &salt_b).is_err());
|
||||
assert!(crypto_b.decrypt(&enc_a, &salt_a).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_minimum_key_length() {
|
||||
// Exactly 32 bytes should work
|
||||
let key = "a".repeat(super::KEY_SIZE);
|
||||
assert!(SecretsCrypto::new(SecretString::from(key)).is_ok());
|
||||
|
||||
// 31 bytes should fail
|
||||
let short = "a".repeat(super::KEY_SIZE - 1);
|
||||
assert!(SecretsCrypto::new(SecretString::from(short)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_longer_master_key_works() {
|
||||
// Keys longer than 32 bytes are fine (HKDF handles it)
|
||||
let long_key = "x".repeat(128);
|
||||
let crypto = SecretsCrypto::new(SecretString::from(long_key)).unwrap();
|
||||
let plaintext = b"works with long key";
|
||||
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
|
||||
assert_eq!(decrypted.expose(), "works with long key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_redacts_master_key() {
|
||||
let crypto = test_crypto();
|
||||
let debug = format!("{:?}", crypto);
|
||||
assert!(debug.contains("REDACTED"));
|
||||
assert!(!debug.contains("0123456789abcdef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_structure() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"hello";
|
||||
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
|
||||
// encrypted = nonce (12) + ciphertext (plaintext_len) + tag (16)
|
||||
assert_eq!(
|
||||
encrypted.len(),
|
||||
super::NONCE_SIZE + plaintext.len() + super::TAG_SIZE
|
||||
);
|
||||
assert_eq!(salt.len(), super::SALT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_nonce_fails() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = b"sensitive";
|
||||
let (mut encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
|
||||
// Flip a bit in the nonce region (first 12 bytes)
|
||||
encrypted[0] ^= 0x01;
|
||||
|
||||
let result = crypto.decrypt(&encrypted, &salt);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_plaintext_roundtrip() {
|
||||
let crypto = test_crypto();
|
||||
let plaintext = "password: p@$$w0rd! 你好 🔑".as_bytes();
|
||||
let (encrypted, salt) = crypto.encrypt(plaintext).unwrap();
|
||||
let decrypted = crypto.decrypt(&encrypted, &salt).unwrap();
|
||||
assert_eq!(decrypted.expose(), "password: p@$$w0rd! 你好 🔑");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,4 +281,226 @@ mod tests {
|
||||
assert_eq!(params.name, "key");
|
||||
assert_eq!(params.provider, Some("stripe".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_params_name_lowercased() {
|
||||
let params = CreateSecretParams::new("SLACK_BOT_TOKEN", "val");
|
||||
assert_eq!(params.name, "slack_bot_token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_params_with_expiry() {
|
||||
use chrono::Utc;
|
||||
let expiry = Utc::now();
|
||||
let params = CreateSecretParams::new("key", "val").with_expiry(expiry);
|
||||
assert_eq!(params.expires_at, Some(expiry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_ref_without_provider() {
|
||||
let r = SecretRef::new("token");
|
||||
assert_eq!(r.name, "token");
|
||||
assert!(r.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_ref_serde_roundtrip() {
|
||||
let original = SecretRef::new("api_key").with_provider("openai");
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SecretRef = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.name, original.name);
|
||||
assert_eq!(deserialized.provider, original.provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_ref_serde_without_provider() {
|
||||
let original = SecretRef::new("bare_token");
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
assert!(json.contains("\"provider\":null"));
|
||||
let deserialized: SecretRef = serde_json::from_str(&json).unwrap();
|
||||
assert!(deserialized.provider.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_serde_roundtrip_bearer() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::AuthorizationBearer;
|
||||
let json = serde_json::to_string(&loc).unwrap();
|
||||
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, CredentialLocation::AuthorizationBearer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_serde_roundtrip_basic() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::AuthorizationBasic {
|
||||
username: "admin".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&loc).unwrap();
|
||||
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
CredentialLocation::AuthorizationBasic { username } => {
|
||||
assert_eq!(username, "admin");
|
||||
}
|
||||
_ => panic!("expected AuthorizationBasic"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_serde_roundtrip_header() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::Header {
|
||||
name: "X-Api-Key".to_string(),
|
||||
prefix: Some("Token".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&loc).unwrap();
|
||||
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
CredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-Api-Key");
|
||||
assert_eq!(prefix, Some("Token".to_string()));
|
||||
}
|
||||
_ => panic!("expected Header"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_serde_roundtrip_query_param() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::QueryParam {
|
||||
name: "access_token".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&loc).unwrap();
|
||||
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
CredentialLocation::QueryParam { name } => assert_eq!(name, "access_token"),
|
||||
_ => panic!("expected QueryParam"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_serde_roundtrip_url_path() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::UrlPath {
|
||||
placeholder: "{api_key}".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&loc).unwrap();
|
||||
let back: CredentialLocation = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
CredentialLocation::UrlPath { placeholder } => assert_eq!(placeholder, "{api_key}"),
|
||||
_ => panic!("expected UrlPath"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_location_default_is_bearer() {
|
||||
use crate::secrets::types::CredentialLocation;
|
||||
let loc = CredentialLocation::default();
|
||||
assert!(matches!(loc, CredentialLocation::AuthorizationBearer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_mapping_bearer_constructor() {
|
||||
use crate::secrets::types::CredentialMapping;
|
||||
let m = CredentialMapping::bearer("my_token", "*.example.com");
|
||||
assert_eq!(m.secret_name, "my_token");
|
||||
assert!(matches!(
|
||||
m.location,
|
||||
crate::secrets::types::CredentialLocation::AuthorizationBearer
|
||||
));
|
||||
assert_eq!(m.host_patterns, vec!["*.example.com".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_mapping_header_constructor() {
|
||||
use crate::secrets::types::CredentialMapping;
|
||||
let m = CredentialMapping::header("key", "X-Custom", "api.host.com");
|
||||
assert_eq!(m.secret_name, "key");
|
||||
match &m.location {
|
||||
crate::secrets::types::CredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-Custom");
|
||||
assert!(prefix.is_none());
|
||||
}
|
||||
_ => panic!("expected Header"),
|
||||
}
|
||||
assert_eq!(m.host_patterns, vec!["api.host.com".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_mapping_serde_roundtrip() {
|
||||
use crate::secrets::types::CredentialMapping;
|
||||
let original = CredentialMapping::bearer("tok", "*.api.com");
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let back: CredentialMapping = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.secret_name, "tok");
|
||||
assert_eq!(back.host_patterns, vec!["*.api.com".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypted_secret_invalid_utf8() {
|
||||
let result = DecryptedSecret::from_bytes(vec![0xFF, 0xFE, 0x00]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypted_secret_empty() {
|
||||
let secret = DecryptedSecret::from_bytes(Vec::new()).unwrap();
|
||||
assert!(secret.is_empty());
|
||||
assert_eq!(secret.len(), 0);
|
||||
assert_eq!(secret.expose(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypted_secret_clone() {
|
||||
let original = DecryptedSecret::from_bytes(b"cloneable".to_vec()).unwrap();
|
||||
let cloned = original.clone();
|
||||
assert_eq!(cloned.expose(), "cloneable");
|
||||
assert_eq!(cloned.len(), original.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_debug_redacts_fields() {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
let secret = crate::secrets::types::Secret {
|
||||
id: Uuid::nil(),
|
||||
user_id: "user1".to_string(),
|
||||
name: "test_key".to_string(),
|
||||
encrypted_value: vec![1, 2, 3],
|
||||
key_salt: vec![4, 5, 6],
|
||||
provider: Some("aws".to_string()),
|
||||
expires_at: None,
|
||||
last_used_at: None,
|
||||
usage_count: 5,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
let debug = format!("{:?}", secret);
|
||||
assert!(debug.contains("REDACTED"));
|
||||
assert!(!debug.contains("[1, 2, 3]"));
|
||||
assert!(!debug.contains("[4, 5, 6]"));
|
||||
assert!(debug.contains("test_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secret_error_display() {
|
||||
use crate::secrets::types::SecretError;
|
||||
assert_eq!(
|
||||
SecretError::NotFound("foo".into()).to_string(),
|
||||
"Secret not found: foo"
|
||||
);
|
||||
assert_eq!(SecretError::Expired.to_string(), "Secret has expired");
|
||||
assert_eq!(
|
||||
SecretError::InvalidMasterKey.to_string(),
|
||||
"Invalid master key"
|
||||
);
|
||||
assert_eq!(
|
||||
SecretError::InvalidUtf8.to_string(),
|
||||
"Secret value is not valid UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
SecretError::AccessDenied.to_string(),
|
||||
"Secret access denied for tool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user