1pub mod api_client;
4pub mod config;
5pub mod embedded_ops;
6pub mod net;
7pub mod remote_ops;
8pub mod server;
9pub mod tool_args;
10
11pub use config::{McpMode, McpSettings};
12pub use server::{run_http, HortoMcp, DEFAULT_HTTP_LISTEN_BOX, DEFAULT_HTTP_LISTEN_PC};
13
14#[cfg(test)]
15mod tests {
16 use std::sync::{Mutex, OnceLock};
17
18 use serde_json::json;
19 use wiremock::matchers::{header, method, path};
20 use wiremock::{Mock, MockServer, ResponseTemplate};
21
22 use crate::api_client::StatusApiClient;
23 use crate::config::{McpMode, McpSettings};
24 use crate::server::{default_listen_for, HortoMcp};
25 use rmcp::ServerHandler;
26
27 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
28 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
29 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
30 }
31
32 fn restore_env(key: &str, prev: Option<String>) {
33 match prev {
34 Some(v) => std::env::set_var(key, v),
35 None => std::env::remove_var(key),
36 }
37 }
38
39 #[test]
40 fn mode_and_defaults() {
41 let _g = env_lock();
42 let keys = [
43 "HORTO_MCP_MODE",
44 "HORTO_STATUS_API_URL",
45 "HORTO_API_TOKEN",
46 "HORTO_MCP_TOKEN",
47 "HORTO_REMOTE_HOST",
48 "HORTO_RELEASE_TAG",
49 "HORTO_BIN_DIR",
50 "HORTO_INSTALL_SSH_KEY",
51 ];
52 let prev: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
53 for k in keys {
54 std::env::remove_var(k);
55 }
56
57 assert_eq!(McpMode::from_env(), McpMode::Pc);
58 let pc = McpSettings::from_env();
59 assert_eq!(pc.mode, McpMode::Pc);
60 assert!(pc.status_api_url.contains("8787"));
61 assert!(pc.http_bearer().is_none());
62 assert_eq!(default_listen_for(McpMode::Pc), "127.0.0.1:8790");
63 assert_eq!(default_listen_for(McpMode::Box), "0.0.0.0:8790");
64
65 std::env::set_var("HORTO_MCP_MODE", "box");
66 std::env::set_var("HORTO_API_TOKEN", "tok");
67 let box_s = McpSettings::from_env();
68 assert_eq!(box_s.mode, McpMode::Box);
69 assert_eq!(box_s.http_bearer(), Some("tok"));
70 assert!(box_s.status_api_url.contains("127.0.0.1"));
71
72 std::env::set_var("HORTO_MCP_TOKEN", "mcp-only");
73 let mcp = McpSettings::from_env();
74 assert_eq!(mcp.http_bearer(), Some("mcp-only"));
75
76 for (k, v) in prev {
77 restore_env(k, v);
78 }
79 }
80
81 #[test]
82 fn server_info_identity() {
83 let settings = McpSettings {
84 mode: McpMode::Pc,
85 status_api_url: "http://127.0.0.1:8787".into(),
86 api_token: None,
87 mcp_token: None,
88 remote_host: None,
89 release_tag: None,
90 bin_dir: None,
91 install_ssh_key: false,
92 };
93 let info = HortoMcp::new(settings).get_info();
94 assert_eq!(info.server_info.name.as_str(), "horto-os-ui");
95 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
96 assert!(info.capabilities.tools.is_some());
97 }
98
99 #[tokio::test]
100 async fn api_client_health_status_backup() {
101 let server = MockServer::start().await;
102 Mock::given(method("GET"))
103 .and(path("/health"))
104 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
105 .mount(&server)
106 .await;
107 Mock::given(method("GET"))
108 .and(path("/v1/status"))
109 .and(header("authorization", "Bearer secret"))
110 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hostname": "box"})))
111 .mount(&server)
112 .await;
113 Mock::given(method("POST"))
114 .and(path("/v1/backup/etc"))
115 .and(header("authorization", "Bearer secret"))
116 .and(header("x-horto-confirm", "backup-etc"))
117 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"path": "/backup"})))
118 .mount(&server)
119 .await;
120
121 let settings = McpSettings {
122 mode: McpMode::Pc,
123 status_api_url: server.uri(),
124 api_token: Some("secret".into()),
125 mcp_token: Some("secret".into()),
126 remote_host: None,
127 release_tag: None,
128 bin_dir: None,
129 install_ssh_key: false,
130 };
131 let client = StatusApiClient::from_settings(&settings).expect("client");
132 let health = client.health().await.expect("health");
133 assert_eq!(health["ok"], true);
134 let status = client.status().await.expect("status");
135 assert_eq!(status["hostname"], "box");
136 let backup = client.backup_etc("backup-etc").await.expect("backup");
137 assert_eq!(backup["path"], "/backup");
138 assert!(client.backup_etc("nope").await.is_err());
139 }
140
141 #[tokio::test]
142 async fn backup_requires_token() {
143 let settings = McpSettings {
144 mode: McpMode::Pc,
145 status_api_url: "http://127.0.0.1:9".into(),
146 api_token: None,
147 mcp_token: None,
148 remote_host: None,
149 release_tag: None,
150 bin_dir: None,
151 install_ssh_key: false,
152 };
153 let client = StatusApiClient::from_settings(&settings).expect("client");
154 assert!(client.backup_etc("backup-etc").await.is_err());
155 }
156}