Skip to content

Commit 3df7b10

Browse files
committed
test(coverage): batch 23 — 0% modules: socket models, encryption core, daemon, logging (tinyhumansai#530)
Add 32 new tests across 4 previously-untested modules: - api/models/socket: ConnectionStatus serde (all 5 variants), SocketState default/roundtrip/skip-none, SocketMessage, McpRequest/Response/Error - encryption/core: key derivation, encrypt/decrypt roundtrip (bytes + string), wrong-key rejection, salt randomness, nonce uniqueness, empty string, EncryptedPayload serde, KeyFile serde - service/daemon: state_file_path with absolute and relative config paths - core/logging: level_tag (all 5 levels), short_target, parse_log_file_constraints, CliLogDefault Debug/Eq All 4345 tests pass.
1 parent bfff791 commit 3df7b10

4 files changed

Lines changed: 356 additions & 0 deletions

File tree

src/api/models/socket.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,147 @@ pub struct McpError {
7272
#[serde(skip_serializing_if = "Option::is_none")]
7373
pub data: Option<serde_json::Value>,
7474
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::*;
79+
use serde_json::json;
80+
81+
#[test]
82+
fn connection_status_default_is_disconnected() {
83+
assert_eq!(ConnectionStatus::default(), ConnectionStatus::Disconnected);
84+
}
85+
86+
#[test]
87+
fn connection_status_serde_roundtrip() {
88+
for status in [
89+
ConnectionStatus::Disconnected,
90+
ConnectionStatus::Connecting,
91+
ConnectionStatus::Connected,
92+
ConnectionStatus::Reconnecting,
93+
ConnectionStatus::Error,
94+
] {
95+
let json = serde_json::to_string(&status).unwrap();
96+
let back: ConnectionStatus = serde_json::from_str(&json).unwrap();
97+
assert_eq!(back, status);
98+
}
99+
}
100+
101+
#[test]
102+
fn connection_status_serializes_lowercase() {
103+
assert_eq!(
104+
serde_json::to_string(&ConnectionStatus::Connected).unwrap(),
105+
"\"connected\""
106+
);
107+
assert_eq!(
108+
serde_json::to_string(&ConnectionStatus::Reconnecting).unwrap(),
109+
"\"reconnecting\""
110+
);
111+
}
112+
113+
#[test]
114+
fn socket_state_default() {
115+
let state = SocketState::default();
116+
assert_eq!(state.status, ConnectionStatus::Disconnected);
117+
assert!(state.socket_id.is_none());
118+
assert!(state.error.is_none());
119+
}
120+
121+
#[test]
122+
fn socket_state_serde_roundtrip() {
123+
let state = SocketState {
124+
status: ConnectionStatus::Connected,
125+
socket_id: Some("abc-123".into()),
126+
error: None,
127+
};
128+
let json = serde_json::to_string(&state).unwrap();
129+
let back: SocketState = serde_json::from_str(&json).unwrap();
130+
assert_eq!(back.status, ConnectionStatus::Connected);
131+
assert_eq!(back.socket_id.as_deref(), Some("abc-123"));
132+
}
133+
134+
#[test]
135+
fn socket_state_skips_none_error_in_json() {
136+
let state = SocketState::default();
137+
let json = serde_json::to_string(&state).unwrap();
138+
assert!(!json.contains("error"));
139+
}
140+
141+
#[test]
142+
fn socket_message_serde() {
143+
let msg = SocketMessage {
144+
event: "test".into(),
145+
data: json!({"key": "val"}),
146+
};
147+
let json = serde_json::to_string(&msg).unwrap();
148+
let back: SocketMessage = serde_json::from_str(&json).unwrap();
149+
assert_eq!(back.event, "test");
150+
assert_eq!(back.data["key"], "val");
151+
}
152+
153+
#[test]
154+
fn mcp_request_serde() {
155+
let req = McpRequest {
156+
jsonrpc: "2.0".into(),
157+
id: json!(1),
158+
method: "test_method".into(),
159+
params: Some(json!({"a": 1})),
160+
};
161+
let json = serde_json::to_string(&req).unwrap();
162+
let back: McpRequest = serde_json::from_str(&json).unwrap();
163+
assert_eq!(back.method, "test_method");
164+
}
165+
166+
#[test]
167+
fn mcp_request_without_params() {
168+
let req = McpRequest {
169+
jsonrpc: "2.0".into(),
170+
id: json!("req-1"),
171+
method: "ping".into(),
172+
params: None,
173+
};
174+
let json = serde_json::to_string(&req).unwrap();
175+
assert!(!json.contains("params"));
176+
}
177+
178+
#[test]
179+
fn mcp_response_success() {
180+
let resp = McpResponse {
181+
jsonrpc: "2.0".into(),
182+
id: json!(1),
183+
result: Some(json!({"ok": true})),
184+
error: None,
185+
};
186+
let json = serde_json::to_string(&resp).unwrap();
187+
assert!(json.contains("result"));
188+
assert!(!json.contains("error"));
189+
}
190+
191+
#[test]
192+
fn mcp_response_error() {
193+
let resp = McpResponse {
194+
jsonrpc: "2.0".into(),
195+
id: json!(1),
196+
result: None,
197+
error: Some(McpError {
198+
code: -32601,
199+
message: "Method not found".into(),
200+
data: None,
201+
}),
202+
};
203+
let json = serde_json::to_string(&resp).unwrap();
204+
assert!(json.contains("-32601"));
205+
assert!(!json.contains("result"));
206+
}
207+
208+
#[test]
209+
fn mcp_error_with_data() {
210+
let err = McpError {
211+
code: -32600,
212+
message: "Invalid Request".into(),
213+
data: Some(json!({"detail": "missing method"})),
214+
};
215+
let json = serde_json::to_string(&err).unwrap();
216+
assert!(json.contains("detail"));
217+
}
218+
}

src/core/logging.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,54 @@ pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) {
217217
let _ = tracing_log::LogTracer::init();
218218
});
219219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::*;
224+
225+
#[test]
226+
fn level_tag_maps_all_levels() {
227+
assert_eq!(level_tag(&Level::ERROR), "ERR");
228+
assert_eq!(level_tag(&Level::WARN), "WRN");
229+
assert_eq!(level_tag(&Level::INFO), "INF");
230+
assert_eq!(level_tag(&Level::DEBUG), "DBG");
231+
assert_eq!(level_tag(&Level::TRACE), "TRC");
232+
}
233+
234+
#[test]
235+
fn short_target_strips_crate_prefix() {
236+
assert_eq!(short_target("openhuman_core::rpc"), "rpc");
237+
assert_eq!(short_target("openhuman_core::openhuman::memory"), "memory");
238+
}
239+
240+
#[test]
241+
fn short_target_passes_through_simple_names() {
242+
assert_eq!(short_target("mymodule"), "mymodule");
243+
assert_eq!(short_target(""), "");
244+
}
245+
246+
#[test]
247+
fn parse_log_file_constraints_empty_when_unset() {
248+
// Remove the env var if it exists
249+
let prev = std::env::var("OPENHUMAN_LOG_FILE_CONSTRAINTS").ok();
250+
std::env::remove_var("OPENHUMAN_LOG_FILE_CONSTRAINTS");
251+
let constraints = parse_log_file_constraints();
252+
assert!(constraints.is_empty());
253+
if let Some(v) = prev {
254+
std::env::set_var("OPENHUMAN_LOG_FILE_CONSTRAINTS", v);
255+
}
256+
}
257+
258+
#[test]
259+
fn cli_log_default_debug_impl() {
260+
// Just verify Debug doesn't panic
261+
let _ = format!("{:?}", CliLogDefault::Global);
262+
let _ = format!("{:?}", CliLogDefault::AutocompleteOnly);
263+
}
264+
265+
#[test]
266+
fn cli_log_default_eq() {
267+
assert_eq!(CliLogDefault::Global, CliLogDefault::Global);
268+
assert_ne!(CliLogDefault::Global, CliLogDefault::AutocompleteOnly);
269+
}
270+
}

src/openhuman/encryption/core.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,135 @@ pub async fn ai_decrypt(password: String, encrypted: String) -> Result<String, S
180180
let key = EncryptionKey::derive(&password, &key_file.salt)?;
181181
key.decrypt_string(&encrypted)
182182
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
use super::*;
187+
188+
#[test]
189+
fn generate_salt_has_correct_length() {
190+
let salt = EncryptionKey::generate_salt();
191+
assert_eq!(salt.len(), SALT_LENGTH);
192+
}
193+
194+
#[test]
195+
fn generate_salt_is_random() {
196+
let s1 = EncryptionKey::generate_salt();
197+
let s2 = EncryptionKey::generate_salt();
198+
assert_ne!(s1, s2);
199+
}
200+
201+
#[test]
202+
fn derive_key_succeeds_with_valid_password() {
203+
let salt = EncryptionKey::generate_salt();
204+
let key = EncryptionKey::derive("test-password", &salt);
205+
assert!(key.is_ok());
206+
}
207+
208+
#[test]
209+
fn encrypt_decrypt_roundtrip_bytes() {
210+
let salt = EncryptionKey::generate_salt();
211+
let key = EncryptionKey::derive("my-secret-password", &salt).unwrap();
212+
213+
let plaintext = b"Hello, World! This is a secret message.";
214+
let payload = key.encrypt(plaintext).unwrap();
215+
216+
assert!(!payload.ciphertext.is_empty());
217+
assert_eq!(payload.nonce.len(), NONCE_LENGTH);
218+
219+
let decrypted = key.decrypt(&payload).unwrap();
220+
assert_eq!(decrypted, plaintext);
221+
}
222+
223+
#[test]
224+
fn encrypt_decrypt_roundtrip_string() {
225+
let salt = EncryptionKey::generate_salt();
226+
let key = EncryptionKey::derive("password123", &salt).unwrap();
227+
228+
let original = "sensitive API key: sk-1234567890";
229+
let encrypted = key.encrypt_string(original).unwrap();
230+
231+
assert!(!encrypted.is_empty());
232+
assert_ne!(encrypted, original);
233+
234+
let decrypted = key.decrypt_string(&encrypted).unwrap();
235+
assert_eq!(decrypted, original);
236+
}
237+
238+
#[test]
239+
fn decrypt_with_wrong_key_fails() {
240+
let salt = EncryptionKey::generate_salt();
241+
let key1 = EncryptionKey::derive("correct-password", &salt).unwrap();
242+
let key2 = EncryptionKey::derive("wrong-password", &salt).unwrap();
243+
244+
let payload = key1.encrypt(b"secret data").unwrap();
245+
let result = key2.decrypt(&payload);
246+
assert!(result.is_err());
247+
}
248+
249+
#[test]
250+
fn different_salts_produce_different_keys() {
251+
let salt1 = EncryptionKey::generate_salt();
252+
let salt2 = EncryptionKey::generate_salt();
253+
let key1 = EncryptionKey::derive("same-password", &salt1).unwrap();
254+
let key2 = EncryptionKey::derive("same-password", &salt2).unwrap();
255+
256+
let payload = key1.encrypt(b"test").unwrap();
257+
// key2 has different salt, so it can't decrypt key1's payload
258+
assert!(key2.decrypt(&payload).is_err());
259+
}
260+
261+
#[test]
262+
fn each_encryption_produces_different_ciphertext() {
263+
let salt = EncryptionKey::generate_salt();
264+
let key = EncryptionKey::derive("password", &salt).unwrap();
265+
266+
let payload1 = key.encrypt(b"same plaintext").unwrap();
267+
let payload2 = key.encrypt(b"same plaintext").unwrap();
268+
269+
// Different nonces → different ciphertext
270+
assert_ne!(payload1.ciphertext, payload2.ciphertext);
271+
assert_ne!(payload1.nonce, payload2.nonce);
272+
}
273+
274+
#[test]
275+
fn encrypted_payload_serde_roundtrip() {
276+
let salt = EncryptionKey::generate_salt();
277+
let key = EncryptionKey::derive("pw", &salt).unwrap();
278+
let payload = key.encrypt(b"data").unwrap();
279+
280+
let json = serde_json::to_string(&payload).unwrap();
281+
let back: EncryptedPayload = serde_json::from_str(&json).unwrap();
282+
assert_eq!(back.ciphertext, payload.ciphertext);
283+
assert_eq!(back.nonce, payload.nonce);
284+
}
285+
286+
#[test]
287+
fn decrypt_string_rejects_invalid_json() {
288+
let salt = EncryptionKey::generate_salt();
289+
let key = EncryptionKey::derive("pw", &salt).unwrap();
290+
let result = key.decrypt_string("not-json");
291+
assert!(result.is_err());
292+
}
293+
294+
#[test]
295+
fn encrypt_empty_string() {
296+
let salt = EncryptionKey::generate_salt();
297+
let key = EncryptionKey::derive("pw", &salt).unwrap();
298+
let encrypted = key.encrypt_string("").unwrap();
299+
let decrypted = key.decrypt_string(&encrypted).unwrap();
300+
assert_eq!(decrypted, "");
301+
}
302+
303+
#[test]
304+
fn key_file_serde_roundtrip() {
305+
let kf = KeyFile {
306+
salt: vec![1, 2, 3, 4],
307+
version: 1,
308+
};
309+
let json = serde_json::to_string(&kf).unwrap();
310+
let back: KeyFile = serde_json::from_str(&json).unwrap();
311+
assert_eq!(back.salt, vec![1, 2, 3, 4]);
312+
assert_eq!(back.version, 1);
313+
}
314+
}

src/openhuman/service/daemon.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,32 @@ pub fn state_file_path(config: &Config) -> PathBuf {
99
.map_or_else(|| PathBuf::from("."), PathBuf::from)
1010
.join("daemon_state.json")
1111
}
12+
13+
#[cfg(test)]
14+
mod tests {
15+
use super::*;
16+
17+
#[test]
18+
fn state_file_path_uses_config_parent() {
19+
let config = Config {
20+
config_path: PathBuf::from("/home/user/.openhuman/config.toml"),
21+
..Config::default()
22+
};
23+
let path = state_file_path(&config);
24+
assert_eq!(
25+
path,
26+
PathBuf::from("/home/user/.openhuman/daemon_state.json")
27+
);
28+
}
29+
30+
#[test]
31+
fn state_file_path_relative_config() {
32+
let config = Config {
33+
config_path: PathBuf::from("config.toml"),
34+
..Config::default()
35+
};
36+
let path = state_file_path(&config);
37+
// "config.toml".parent() = "" which joins to "daemon_state.json"
38+
assert!(path.to_str().unwrap().contains("daemon_state.json"));
39+
}
40+
}

0 commit comments

Comments
 (0)