mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
375 lines
12 KiB
Rust
375 lines
12 KiB
Rust
#![expect(
|
|
clippy::unwrap_used,
|
|
reason = "SQLite MCP store integration tests use panic-on-failure fixture setup"
|
|
)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::error::Error as _;
|
|
|
|
use fabro_db::Database;
|
|
use fabro_mcp_store::{McpServerStore, McpServerStoreError, import_legacy_directory_once};
|
|
use fabro_types::settings::run::{McpHttpProtocol, McpTransport};
|
|
use fabro_types::{McpServerDraft, McpServerId, McpServerReplace, McpServerRevision};
|
|
use tokio::fs;
|
|
|
|
async fn test_database() -> (tempfile::TempDir, Database) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let database = Database::connect(dir.path().join("fabro.sqlite3"))
|
|
.await
|
|
.unwrap();
|
|
database.migrate().await.unwrap();
|
|
(dir, database)
|
|
}
|
|
|
|
fn http_transport(url: &str) -> McpTransport {
|
|
McpTransport::Http {
|
|
protocol: McpHttpProtocol::default(),
|
|
url: url.to_string(),
|
|
headers: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn draft(id: &str, display_name: &str) -> McpServerDraft {
|
|
McpServerDraft {
|
|
id: McpServerId::new(id).unwrap(),
|
|
display_name: display_name.to_string(),
|
|
description: None,
|
|
transport: http_transport("https://example.com/mcp"),
|
|
startup_timeout_secs: 10,
|
|
tool_timeout_secs: 60,
|
|
}
|
|
}
|
|
|
|
fn replacement(display_name: &str) -> McpServerReplace {
|
|
McpServerReplace {
|
|
display_name: display_name.to_string(),
|
|
description: Some("updated".to_string()),
|
|
transport: http_transport("https://example.com/mcp/v2"),
|
|
startup_timeout_secs: 15,
|
|
tool_timeout_secs: 90,
|
|
}
|
|
}
|
|
|
|
fn legacy_toml(display_name: &str, url: &str) -> String {
|
|
format!(
|
|
r#"display_name = "{display_name}"
|
|
startup_timeout_secs = 10
|
|
tool_timeout_secs = 60
|
|
|
|
[transport]
|
|
type = "http"
|
|
url = "{url}"
|
|
|
|
[transport.headers]
|
|
"#
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_database_loads_empty_store() {
|
|
let (_dir, database) = test_database().await;
|
|
let store = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
|
|
assert!(store.list().is_empty());
|
|
assert!(store.ids().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_get_list_replace_delete_and_reload_round_trip() {
|
|
let (_dir, database) = test_database().await;
|
|
let store = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
|
|
let created = store.create(draft("sentry", "Sentry")).await.unwrap();
|
|
assert_eq!(store.get(&created.id).unwrap(), created);
|
|
assert_eq!(store.list(), vec![created.clone()]);
|
|
assert_eq!(store.ids(), vec![created.id.clone()]);
|
|
|
|
let reloaded = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
assert_eq!(reloaded.get(&created.id).unwrap(), created);
|
|
|
|
let replaced = store
|
|
.replace(&created.id, &created.revision, replacement("Sentry v2"))
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(replaced.revision, created.revision);
|
|
assert_eq!(replaced.display_name, "Sentry v2");
|
|
|
|
store
|
|
.delete(&replaced.id, &replaced.revision)
|
|
.await
|
|
.unwrap();
|
|
assert!(store.get(&replaced.id).is_none());
|
|
assert!(
|
|
McpServerStore::load(database.clone_pool())
|
|
.await
|
|
.unwrap()
|
|
.list()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn all_transport_variants_round_trip_with_sorted_map_json() {
|
|
let (_dir, database) = test_database().await;
|
|
let store = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
let definitions = [
|
|
McpServerDraft {
|
|
id: McpServerId::new("stdio").unwrap(),
|
|
display_name: "Stdio".to_string(),
|
|
description: None,
|
|
transport: McpTransport::Stdio {
|
|
command: vec!["server".to_string(), "--flag".to_string()],
|
|
env: HashMap::from([
|
|
("Z_KEY".to_string(), "z".to_string()),
|
|
("A_KEY".to_string(), "a".to_string()),
|
|
]),
|
|
},
|
|
startup_timeout_secs: 1,
|
|
tool_timeout_secs: 2,
|
|
},
|
|
McpServerDraft {
|
|
id: McpServerId::new("http").unwrap(),
|
|
display_name: "HTTP".to_string(),
|
|
description: None,
|
|
transport: McpTransport::Http {
|
|
protocol: McpHttpProtocol::Sse,
|
|
url: "https://example.com/mcp".to_string(),
|
|
headers: HashMap::from([("Authorization".to_string(), "secret".to_string())]),
|
|
},
|
|
startup_timeout_secs: 3,
|
|
tool_timeout_secs: 4,
|
|
},
|
|
McpServerDraft {
|
|
id: McpServerId::new("sandbox").unwrap(),
|
|
display_name: "Sandbox".to_string(),
|
|
description: None,
|
|
transport: McpTransport::Sandbox {
|
|
protocol: McpHttpProtocol::StreamableHttp,
|
|
command: vec!["server".to_string()],
|
|
port: 3000,
|
|
env: HashMap::from([("TOKEN".to_string(), "secret".to_string())]),
|
|
},
|
|
startup_timeout_secs: 5,
|
|
tool_timeout_secs: 6,
|
|
},
|
|
];
|
|
|
|
for definition in definitions.clone() {
|
|
store.create(definition).await.unwrap();
|
|
}
|
|
let reloaded = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
for expected in definitions {
|
|
let actual = reloaded.get(&expected.id).unwrap();
|
|
assert_eq!(actual.display_name, expected.display_name);
|
|
assert_eq!(actual.transport, expected.transport);
|
|
}
|
|
|
|
let env_json: String =
|
|
sqlx::query_scalar("SELECT env_json FROM mcp_servers WHERE id = 'stdio'")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(env_json, r#"{"A_KEY":"a","Z_KEY":"z"}"#);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_create_is_rejected() {
|
|
let (_dir, database) = test_database().await;
|
|
let first = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
let second = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
first.create(draft("sentry", "Sentry")).await.unwrap();
|
|
|
|
let err = second
|
|
.create(draft("sentry", "Duplicate"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, McpServerStoreError::AlreadyExists { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn revision_guard_rejects_stale_independent_store() {
|
|
let (_dir, database) = test_database().await;
|
|
let creator = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
let created = creator.create(draft("sentry", "Sentry")).await.unwrap();
|
|
let first = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
let second = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
|
|
first
|
|
.replace(&created.id, &created.revision, replacement("Winner"))
|
|
.await
|
|
.unwrap();
|
|
let err = second
|
|
.replace(&created.id, &created.revision, replacement("Loser"))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(err, McpServerStoreError::StaleRevision { .. }));
|
|
assert_eq!(second.get(&created.id).unwrap(), created);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn imports_legacy_directory_once_without_overwriting_sql() {
|
|
let (dir, database) = test_database().await;
|
|
let store = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
store.create(draft("existing", "SQLite")).await.unwrap();
|
|
let source = dir.path().join("mcps");
|
|
fs::create_dir_all(&source).await.unwrap();
|
|
let existing_bytes = legacy_toml("Legacy", "https://legacy.example.com/mcp");
|
|
let imported_bytes = legacy_toml("Imported", "https://new.example.com/mcp");
|
|
fs::write(source.join("existing.toml"), &existing_bytes)
|
|
.await
|
|
.unwrap();
|
|
fs::write(source.join("new.toml"), &imported_bytes)
|
|
.await
|
|
.unwrap();
|
|
fs::write(source.join("notes.txt"), "preserve in backup")
|
|
.await
|
|
.unwrap();
|
|
|
|
let report = import_legacy_directory_once(database.pool(), &source)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(report.imported_rows, 1);
|
|
assert_eq!(report.skipped_rows, 1);
|
|
assert_eq!(report.names, vec!["new"]);
|
|
assert!(!source.exists());
|
|
assert!(report.backup_path.join("notes.txt").exists());
|
|
let reloaded = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
assert_eq!(
|
|
reloaded
|
|
.get(&McpServerId::new("existing").unwrap())
|
|
.unwrap()
|
|
.display_name,
|
|
"SQLite"
|
|
);
|
|
let imported = reloaded.get(&McpServerId::new("new").unwrap()).unwrap();
|
|
assert_eq!(imported.display_name, "Imported");
|
|
assert_eq!(
|
|
imported.revision,
|
|
McpServerRevision::from_bytes(imported_bytes.as_bytes())
|
|
);
|
|
assert!(
|
|
import_legacy_directory_once(database.pool(), &source)
|
|
.await
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_legacy_toml_does_not_import_or_rename() {
|
|
let (dir, database) = test_database().await;
|
|
let source = dir.path().join("mcps");
|
|
fs::create_dir_all(&source).await.unwrap();
|
|
fs::write(
|
|
source.join("valid.toml"),
|
|
legacy_toml("Valid", "https://example.com/mcp"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
fs::write(source.join("broken.toml"), "not valid toml =")
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = import_legacy_directory_once(database.pool(), &source)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(err, McpServerStoreError::Parse { .. }));
|
|
assert!(source.exists());
|
|
assert!(
|
|
McpServerStore::load(database.clone_pool())
|
|
.await
|
|
.unwrap()
|
|
.list()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_parse_error_chain_does_not_expose_transport_values() {
|
|
let (dir, database) = test_database().await;
|
|
let source = dir.path().join("mcps");
|
|
fs::create_dir_all(&source).await.unwrap();
|
|
fs::write(
|
|
source.join("broken.toml"),
|
|
r#"
|
|
display_name = "Broken"
|
|
startup_timeout_secs = 10
|
|
tool_timeout_secs = 60
|
|
|
|
[transport]
|
|
type = "http"
|
|
url = "https://example.com/mcp"
|
|
|
|
[transport.headers]
|
|
Authorization = "do-not-print" trailing-invalid-content
|
|
"#,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = import_legacy_directory_once(database.pool(), &source)
|
|
.await
|
|
.unwrap_err();
|
|
let mut rendered = err.to_string();
|
|
let mut source = err.source();
|
|
while let Some(err) = source {
|
|
rendered.push_str(&err.to_string());
|
|
source = err.source();
|
|
}
|
|
|
|
assert!(!rendered.contains("do-not-print"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn corrupted_stored_row_returns_typed_error() {
|
|
let (_dir, database) = test_database().await;
|
|
let mut connection = database.pool().acquire().await.unwrap();
|
|
sqlx::query("PRAGMA ignore_check_constraints = ON")
|
|
.execute(&mut *connection)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
r"
|
|
INSERT INTO mcp_servers (
|
|
id, revision, display_name, transport_type, command_json, env_json,
|
|
startup_timeout_secs, tool_timeout_secs
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
",
|
|
)
|
|
.bind("broken")
|
|
.bind("not-a-revision")
|
|
.bind("Broken")
|
|
.bind("stdio")
|
|
.bind(r#"["server"]"#)
|
|
.bind("{}")
|
|
.bind(10_i64)
|
|
.bind(60_i64)
|
|
.execute(&mut *connection)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = McpServerStore::load(database.clone_pool())
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, McpServerStoreError::StoredRevision { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn store_debug_does_not_expose_transport_values() {
|
|
let (_dir, database) = test_database().await;
|
|
let store = McpServerStore::load(database.clone_pool()).await.unwrap();
|
|
let mut secret_draft = draft("secret", "Secret");
|
|
secret_draft.transport = McpTransport::Http {
|
|
protocol: McpHttpProtocol::default(),
|
|
url: "https://example.com/mcp".to_string(),
|
|
headers: HashMap::from([("Authorization".to_string(), "do-not-print".to_string())]),
|
|
};
|
|
store.create(secret_draft).await.unwrap();
|
|
|
|
let debug = format!("{store:?}");
|
|
assert!(!debug.contains("do-not-print"));
|
|
}
|