sequel_mcp/app/
test_mode.rs1use crate::vault::keychain::SecretStore as _;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24pub const EXIT_ISOLATION: i32 = 78;
27
28pub fn is_active() -> bool {
29 std::env::var("SEQUEL_MCP_TEST_MODE").as_deref() == Ok("1")
30}
31
32fn root() -> Result<PathBuf, String> {
33 std::env::var_os("SEQUEL_MCP_TEST_ROOT")
34 .map(PathBuf::from)
35 .ok_or_else(|| {
36 "SEQUEL_MCP_TEST_MODE=1 requires SEQUEL_MCP_TEST_ROOT pointing at the isolated test tree"
37 .to_string()
38 })
39}
40
41fn under(path: &Path, root: &Path) -> bool {
42 path.starts_with(root)
43}
44
45pub fn verify_startup() -> Result<(), String> {
49 if !is_active() {
50 return Ok(());
51 }
52 let root = root()?;
53 let checks: [(&str, PathBuf); 6] = [
54 ("HOME", crate::app::paths::home_dir()),
55 ("config dir", crate::app::paths::config_dir()),
56 ("legacy config dir", crate::app::paths::legacy_config_dir()),
57 ("data dir", crate::app::paths::data_dir()),
58 ("audit db", crate::app::paths::audit_db_path()),
59 ("runtime dir", crate::app::paths::runtime_dir()),
60 ];
61 for (label, path) in checks {
62 if !under(&path, &root) {
63 return Err(format!(
64 "{label} resolves outside SEQUEL_MCP_TEST_ROOT: {} (root {})",
65 path.display(),
66 root.display()
67 ));
68 }
69 }
70 Ok(())
71}
72
73fn endpoint_allowed(host: &str, port: u16) -> bool {
75 let Ok(raw) = std::env::var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS") else {
76 return false;
77 };
78 raw.split(',').any(|entry| {
79 let entry = entry.trim();
80 if entry.is_empty() {
81 return false;
82 }
83 match entry.rsplit_once(':') {
84 Some((h, p)) => h == host && p.parse::<u16>() == Ok(port),
85 None => entry == host,
86 }
87 })
88}
89
90pub fn check_mysql_endpoint(host: &str, port: u16) -> Result<(), String> {
94 if !is_active() {
95 return Ok(());
96 }
97 let h = host.trim_matches(['[', ']']);
98 let loopback = matches!(h, "127.0.0.1" | "::1" | "localhost");
99 if loopback || endpoint_allowed(h, port) {
100 return Ok(());
101 }
102 Err(format!(
103 "MySQL endpoint {h}:{port} is not loopback and not in SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS; refusing before connect"
104 ))
105}
106
107pub fn check_sqlite_path(path: &Path) -> Result<(), String> {
110 if !is_active() {
111 return Ok(());
112 }
113 if path == Path::new(":memory:") {
114 return Ok(());
115 }
116 let root = root()?;
117 if under(path, &root) {
118 return Ok(());
119 }
120 Err(format!(
121 "SQLite path {} is outside SEQUEL_MCP_TEST_ROOT; refusing to open",
122 path.display()
123 ))
124}
125
126pub fn secret_store() -> Arc<dyn crate::vault::keychain::SecretStore> {
130 let store = Arc::new(crate::vault::keychain::InMemorySecretStore::new());
131 if let Ok(raw) = std::env::var("SEQUEL_MCP_TEST_SECRETS")
132 && let Ok(map) = serde_json::from_str::<serde_json::Value>(&raw)
133 && let Some(obj) = map.as_object()
134 {
135 for (conn, users) in obj {
136 let Some(users) = users.as_object() else {
137 continue;
138 };
139 for (user, password) in users {
140 if let Some(password) = password.as_str() {
141 let _ = store.set_password(conn, user, password);
142 }
143 }
144 }
145 }
146 store
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
157
158 fn set_var(key: &str, value: impl AsRef<std::ffi::OsStr>) {
159 unsafe { std::env::set_var(key, value) };
163 }
164
165 fn remove_var(key: &str) {
166 unsafe { std::env::remove_var(key) };
168 }
169
170 #[test]
171 fn inactive_by_default_and_allows_everything() {
172 let _g = ENV_LOCK.lock().unwrap();
173 remove_var("SEQUEL_MCP_TEST_MODE");
174 assert!(!is_active());
175 assert!(verify_startup().is_ok());
176 assert!(check_mysql_endpoint("db.prod.example.invalid", 3306).is_ok());
177 assert!(check_sqlite_path(Path::new("/tmp/anywhere.sqlite")).is_ok());
178 }
179
180 #[test]
181 fn active_requires_root_and_paths_under_it() {
182 let _g = ENV_LOCK.lock().unwrap();
183 set_var("SEQUEL_MCP_TEST_MODE", "1");
184 remove_var("SEQUEL_MCP_TEST_ROOT");
185 assert!(verify_startup().is_err());
186
187 let dir = tempfile::TempDir::new().unwrap();
188 set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
189 set_var("HOME", dir.path().join("home"));
190 set_var("XDG_CONFIG_HOME", dir.path().join("config"));
191 set_var("XDG_DATA_HOME", dir.path().join("data"));
192 assert!(verify_startup().is_ok(), "all paths under the root");
193
194 set_var("XDG_DATA_HOME", "/definitely/outside");
196 let err = verify_startup().unwrap_err();
197 assert!(err.contains("outside SEQUEL_MCP_TEST_ROOT"), "{err}");
198 assert!(err.contains("data dir"), "{err}");
199
200 remove_var("SEQUEL_MCP_TEST_MODE");
201 remove_var("SEQUEL_MCP_TEST_ROOT");
202 remove_var("XDG_DATA_HOME");
203 remove_var("XDG_CONFIG_HOME");
204 remove_var("HOME");
205 let _ = dir;
206 }
207
208 #[test]
209 fn mysql_endpoint_gate() {
210 let _g = ENV_LOCK.lock().unwrap();
211 set_var("SEQUEL_MCP_TEST_MODE", "1");
212 let dir = tempfile::TempDir::new().unwrap();
213 set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
214 remove_var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS");
215 assert!(check_mysql_endpoint("127.0.0.1", 3306).is_ok());
216 assert!(check_mysql_endpoint("localhost", 3307).is_ok());
217 assert!(check_mysql_endpoint("::1", 3306).is_ok());
218 let err = check_mysql_endpoint("192.0.2.10", 3306).unwrap_err();
219 assert!(
220 err.contains("192.0.2.10") && err.contains("refusing"),
221 "{err}"
222 );
223
224 set_var(
225 "SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS",
226 "db.test.example,10.1.2.3:3307",
227 );
228 assert!(check_mysql_endpoint("db.test.example", 3306).is_ok());
229 assert!(check_mysql_endpoint("10.1.2.3", 3307).is_ok());
230 assert!(check_mysql_endpoint("10.1.2.3", 3306).is_err());
231
232 remove_var("SEQUEL_MCP_TEST_MODE");
233 remove_var("SEQUEL_MCP_TEST_ROOT");
234 remove_var("SEQUEL_MCP_TEST_ALLOWED_ENDPOINTS");
235 let _ = dir;
236 }
237
238 #[test]
239 fn sqlite_path_gate() {
240 let _g = ENV_LOCK.lock().unwrap();
241 set_var("SEQUEL_MCP_TEST_MODE", "1");
242 let dir = tempfile::TempDir::new().unwrap();
243 set_var("SEQUEL_MCP_TEST_ROOT", dir.path());
244 assert!(check_sqlite_path(Path::new(":memory:")).is_ok());
245 assert!(check_sqlite_path(&dir.path().join("a.sqlite")).is_ok());
246 assert!(check_sqlite_path(Path::new("/tmp/outside.sqlite")).is_err());
247 remove_var("SEQUEL_MCP_TEST_MODE");
248 remove_var("SEQUEL_MCP_TEST_ROOT");
249 let _ = dir;
250 }
251
252 #[test]
253 fn secret_store_seeds_from_env() {
254 let _g = ENV_LOCK.lock().unwrap();
255 set_var(
256 "SEQUEL_MCP_TEST_SECRETS",
257 r#"{"db": {"root": "synthetic-password"}}"#,
258 );
259 let store = secret_store();
260 assert_eq!(
261 store.get_password("db", "root").unwrap().as_str(),
262 "synthetic-password"
263 );
264 assert!(store.get_password("db", "other").is_err());
265 remove_var("SEQUEL_MCP_TEST_SECRETS");
266 }
267}