mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
708 lines
21 KiB
Rust
708 lines
21 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode, header};
|
|
use fabro_db::Database;
|
|
use fabro_mcp_store::McpServerStore;
|
|
use fabro_server::server::build_router;
|
|
use fabro_server::test_support::{TestAppStateBuilder, build_test_router, test_auth_mode};
|
|
use fabro_types::settings::McpTransport;
|
|
use fabro_types::{McpServerDefinition, McpServerId};
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{
|
|
MINIMAL_DOT, checked_response, minimal_manifest_json, response_json, response_status,
|
|
};
|
|
|
|
fn mcp_server_body(id: &str, display_name: &str) -> Value {
|
|
json!({
|
|
"id": id,
|
|
"display_name": display_name,
|
|
"description": "Production MCP server.",
|
|
"transport": {
|
|
"type": "http",
|
|
"protocol": "streamable_http",
|
|
"url": "https://example.com/mcp",
|
|
"headers": {
|
|
"X-Org": "fabro"
|
|
}
|
|
},
|
|
"startup_timeout_secs": 10,
|
|
"tool_timeout_secs": 60
|
|
})
|
|
}
|
|
|
|
fn replacement_body(display_name: &str) -> Value {
|
|
json!({
|
|
"display_name": display_name,
|
|
"description": null,
|
|
"transport": {
|
|
"type": "http",
|
|
"protocol": "sse",
|
|
"url": "https://example.com/mcp/v2",
|
|
"headers": {}
|
|
},
|
|
"startup_timeout_secs": 15,
|
|
"tool_timeout_secs": 90
|
|
})
|
|
}
|
|
|
|
fn mcp_server_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
|
|
let temp_dir = tempfile::tempdir().expect("mcp server test tempdir should be created");
|
|
let active_config_path = temp_dir.path().join("settings.toml");
|
|
let vault_path = temp_dir.path().join("secrets.json");
|
|
let db_path = temp_dir.path().join("db").join("fabro.sqlite3");
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path)
|
|
.vault_path(vault_path)
|
|
.build();
|
|
(build_test_router(state), temp_dir, db_path)
|
|
}
|
|
|
|
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(crate::helpers::api(path))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_vec(&body).expect("mcp server fixture should serialize"),
|
|
))
|
|
.expect("mcp server JSON request should build")
|
|
}
|
|
|
|
fn empty_request(method: Method, path: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(crate::helpers::api(path))
|
|
.body(Body::empty())
|
|
.expect("mcp server request should build")
|
|
}
|
|
|
|
fn request_with_if_match(
|
|
method: Method,
|
|
path: &str,
|
|
revision: &str,
|
|
body: Option<Value>,
|
|
) -> Request<Body> {
|
|
let mut builder = Request::builder()
|
|
.method(method)
|
|
.uri(crate::helpers::api(path))
|
|
.header(header::IF_MATCH, revision);
|
|
let body = match body {
|
|
Some(value) => {
|
|
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
|
Body::from(serde_json::to_vec(&value).expect("mcp server fixture should serialize"))
|
|
}
|
|
None => Body::empty(),
|
|
};
|
|
builder
|
|
.body(body)
|
|
.expect("mcp server If-Match request should build")
|
|
}
|
|
|
|
async fn create_mcp_server(app: &axum::Router, id: &str, name: &str) -> Value {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(json_request(
|
|
Method::POST,
|
|
"/mcp-servers",
|
|
&mcp_server_body(id, name),
|
|
))
|
|
.await
|
|
.expect("create mcp server should respond");
|
|
response_json(response, StatusCode::CREATED, "POST /api/v1/mcp-servers").await
|
|
}
|
|
|
|
fn revision_from(body: &Value) -> &str {
|
|
body["revision"]
|
|
.as_str()
|
|
.expect("mcp server response should include a revision")
|
|
}
|
|
|
|
async fn persisted_mcp_server(db_path: &Path, id: &str) -> Option<McpServerDefinition> {
|
|
let database = Database::connect(db_path)
|
|
.await
|
|
.expect("persisted MCP server database should connect");
|
|
let store = McpServerStore::load(database.clone_pool())
|
|
.await
|
|
.expect("persisted MCP server store should load");
|
|
store.get(&McpServerId::new(id).expect("fixture MCP server id should be valid"))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_mcp_server_list_returns_total_zero() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers"))
|
|
.await
|
|
.expect("list mcp servers should respond");
|
|
let body = response_json(response, StatusCode::OK, "GET /api/v1/mcp-servers").await;
|
|
|
|
assert_eq!(
|
|
body,
|
|
json!({
|
|
"data": [],
|
|
"meta": { "total": 0 }
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_mcp_server_returns_etag_and_persists_sqlite_row() {
|
|
let (app, _temp_dir, db_path) = mcp_server_app();
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(json_request(
|
|
Method::POST,
|
|
"/mcp-servers",
|
|
&mcp_server_body("sentry", "Sentry"),
|
|
))
|
|
.await
|
|
.expect("create mcp server should respond");
|
|
let response =
|
|
checked_response(response, StatusCode::CREATED, "POST /api/v1/mcp-servers").await;
|
|
let etag = response
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.expect("create mcp server should include ETag")
|
|
.to_str()
|
|
.expect("ETag should be ASCII")
|
|
.to_string();
|
|
let body = crate::helpers::body_json(response.into_body()).await;
|
|
|
|
assert_eq!(body["id"], "sentry");
|
|
assert_eq!(body["display_name"], "Sentry");
|
|
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
|
|
assert!(db_path.exists());
|
|
|
|
// The response is the value-omitting view: header *names* are returned, but
|
|
// the stored header value is not.
|
|
assert_eq!(body["transport"]["header_keys"], json!(["X-Org"]));
|
|
assert!(
|
|
body["transport"].get("headers").is_none(),
|
|
"response must not echo transport header values"
|
|
);
|
|
|
|
let persisted = persisted_mcp_server(&db_path, "sentry")
|
|
.await
|
|
.expect("MCP server should be persisted");
|
|
assert_eq!(persisted.display_name, "Sentry");
|
|
assert_eq!(persisted.revision.as_str(), revision_from(&body));
|
|
let McpTransport::Http { headers, .. } = persisted.transport else {
|
|
panic!("persisted MCP server should use HTTP transport")
|
|
};
|
|
assert_eq!(headers.get("X-Org").map(String::as_str), Some("fabro"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_server_round_trips_through_create_get_and_sqlite() {
|
|
let (app, _temp_dir, db_path) = mcp_server_app();
|
|
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
assert_eq!(created["transport"]["type"], "http");
|
|
assert_eq!(created["transport"]["url"], "https://example.com/mcp");
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/sentry"))
|
|
.await
|
|
.expect("get mcp server should respond");
|
|
let retrieved = response_json(response, StatusCode::OK, "GET /api/v1/mcp-servers/sentry").await;
|
|
assert_eq!(retrieved, created);
|
|
|
|
let persisted = persisted_mcp_server(&db_path, "sentry")
|
|
.await
|
|
.expect("MCP server should be persisted");
|
|
assert!(matches!(persisted.transport, McpTransport::Http { .. }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_mcp_servers_returns_items_sorted_by_id() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
create_mcp_server(&app, "zulu", "Zulu").await;
|
|
create_mcp_server(&app, "alpha", "Alpha").await;
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers"))
|
|
.await
|
|
.expect("list mcp servers should respond");
|
|
let body = response_json(response, StatusCode::OK, "GET /api/v1/mcp-servers").await;
|
|
|
|
assert_eq!(body["meta"]["total"], 2);
|
|
assert_eq!(body["data"][0]["id"], "alpha");
|
|
assert_eq!(body["data"][1]["id"], "zulu");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_mcp_server_create_returns_conflict() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
create_mcp_server(&app, "sentry", "Sentry").await;
|
|
|
|
let response = app
|
|
.oneshot(json_request(
|
|
Method::POST,
|
|
"/mcp-servers",
|
|
&mcp_server_body("sentry", "Duplicate"),
|
|
))
|
|
.await
|
|
.expect("duplicate create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
"POST /api/v1/mcp-servers duplicate",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_mcp_server_returns_current_etag() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/sentry"))
|
|
.await
|
|
.expect("get mcp server should respond");
|
|
let response =
|
|
checked_response(response, StatusCode::OK, "GET /api/v1/mcp-servers/sentry").await;
|
|
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.expect("GET mcp server should include ETag"),
|
|
&format!("\"{revision}\"")
|
|
);
|
|
let body = crate::helpers::body_json(response.into_body()).await;
|
|
assert_eq!(body["revision"], revision);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_missing_mcp_server_returns_not_found() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/missing"))
|
|
.await
|
|
.expect("get missing mcp server should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"GET /api/v1/mcp-servers/missing",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_mcp_server_accepts_unquoted_if_match_and_returns_new_etag() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/mcp-servers/sentry",
|
|
revision,
|
|
Some(replacement_body("Sentry v2")),
|
|
))
|
|
.await
|
|
.expect("replace mcp server should respond");
|
|
let response =
|
|
checked_response(response, StatusCode::OK, "PUT /api/v1/mcp-servers/sentry").await;
|
|
let etag = response
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.expect("PUT mcp server should include ETag")
|
|
.to_str()
|
|
.expect("ETag should be ASCII")
|
|
.to_string();
|
|
let body = crate::helpers::body_json(response.into_body()).await;
|
|
|
|
assert_eq!(body["display_name"], "Sentry v2");
|
|
assert_eq!(body["transport"]["protocol"], "sse");
|
|
assert_ne!(body["revision"], revision);
|
|
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_mcp_server_accepts_quoted_if_match() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/mcp-servers/sentry",
|
|
&format!("\"{revision}\""),
|
|
Some(replacement_body("Sentry v2")),
|
|
))
|
|
.await
|
|
.expect("replace mcp server with quoted If-Match should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::OK,
|
|
"PUT /api/v1/mcp-servers/sentry quoted If-Match",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_mcp_server_replace_returns_conflict() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
let stale_revision = revision_from(&created).to_string();
|
|
|
|
let replaced = app
|
|
.clone()
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/mcp-servers/sentry",
|
|
&stale_revision,
|
|
Some(replacement_body("Updated")),
|
|
))
|
|
.await
|
|
.expect("first replace should respond");
|
|
response_status(
|
|
replaced,
|
|
StatusCode::OK,
|
|
"PUT /api/v1/mcp-servers/sentry first replace",
|
|
)
|
|
.await;
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/mcp-servers/sentry",
|
|
&stale_revision,
|
|
Some(replacement_body("Stale")),
|
|
))
|
|
.await
|
|
.expect("stale replace should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
"PUT /api/v1/mcp-servers/sentry stale",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_and_delete_mcp_server_require_if_match() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
create_mcp_server(&app, "sentry", "Sentry").await;
|
|
|
|
let replace_response = app
|
|
.clone()
|
|
.oneshot(json_request(
|
|
Method::PUT,
|
|
"/mcp-servers/sentry",
|
|
&replacement_body("Updated"),
|
|
))
|
|
.await
|
|
.expect("replace without If-Match should respond");
|
|
response_status(
|
|
replace_response,
|
|
StatusCode::PRECONDITION_REQUIRED,
|
|
"PUT /api/v1/mcp-servers/sentry without If-Match",
|
|
)
|
|
.await;
|
|
|
|
let delete_response = app
|
|
.oneshot(empty_request(Method::DELETE, "/mcp-servers/sentry"))
|
|
.await
|
|
.expect("delete without If-Match should respond");
|
|
response_status(
|
|
delete_response,
|
|
StatusCode::PRECONDITION_REQUIRED,
|
|
"DELETE /api/v1/mcp-servers/sentry without If-Match",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_mcp_server_removes_sqlite_row_and_resource() {
|
|
let (app, _temp_dir, db_path) = mcp_server_app();
|
|
let created = create_mcp_server(&app, "sentry", "Sentry").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(request_with_if_match(
|
|
Method::DELETE,
|
|
"/mcp-servers/sentry",
|
|
&format!("\"{revision}\""),
|
|
None,
|
|
))
|
|
.await
|
|
.expect("delete mcp server should respond");
|
|
response_status(
|
|
response,
|
|
StatusCode::NO_CONTENT,
|
|
"DELETE /api/v1/mcp-servers/sentry",
|
|
)
|
|
.await;
|
|
|
|
assert!(persisted_mcp_server(&db_path, "sentry").await.is_none());
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/sentry"))
|
|
.await
|
|
.expect("get deleted mcp server should respond");
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"GET /api/v1/mcp-servers/sentry after delete",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_mcp_server_name_is_unprocessable() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let mut body = mcp_server_body("sentry", "Sentry");
|
|
body["display_name"] = json!(" ");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/mcp-servers", &body))
|
|
.await
|
|
.expect("empty mcp server name create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/mcp-servers empty name",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_transport_command_is_unprocessable() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let mut body = mcp_server_body("local", "Local");
|
|
body["transport"] = json!({
|
|
"type": "stdio",
|
|
"command": [],
|
|
"env": {}
|
|
});
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/mcp-servers", &body))
|
|
.await
|
|
.expect("empty transport command create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/mcp-servers empty transport command",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_transport_type_is_unprocessable() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let mut body = mcp_server_body("sentry", "Sentry");
|
|
body["transport"] = json!({
|
|
"type": "carrier-pigeon",
|
|
"url": "https://example.com/mcp"
|
|
});
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/mcp-servers", &body))
|
|
.await
|
|
.expect("unknown transport type create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/mcp-servers unknown transport type",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_transport_field_is_unprocessable() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
let mut body = mcp_server_body("sentry", "Sentry");
|
|
body["transport"]["unexpected"] = json!("typo");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/mcp-servers", &body))
|
|
.await
|
|
.expect("unknown transport field create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/mcp-servers unknown transport field",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_mcp_server_id_is_bad_request() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/Bad_Id"))
|
|
.await
|
|
.expect("invalid id get should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::BAD_REQUEST,
|
|
"GET /api/v1/mcp-servers/Bad_Id",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn created_mcp_server_can_be_referenced_by_manifest_validation() {
|
|
let (app, _temp_dir, _mcp_dir) = mcp_server_app();
|
|
create_mcp_server(&app, "sentry", "Sentry").await;
|
|
|
|
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
|
|
manifest["workflows"]["workflow.fabro"]["config"] = json!({
|
|
"path": "workflow.toml",
|
|
"source": r#"
|
|
_version = 1
|
|
|
|
[run.agent.mcps.sentry]
|
|
id = "sentry"
|
|
"#
|
|
});
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/validate", &manifest))
|
|
.await
|
|
.expect("manifest validation should respond");
|
|
let body = response_json(response, StatusCode::OK, "POST /api/v1/validate").await;
|
|
|
|
assert_eq!(body["ok"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_legacy_mcp_server_toml_fails_startup() {
|
|
let temp_dir = tempfile::tempdir().expect("mcp server test tempdir should be created");
|
|
let mcp_dir = temp_dir.path().join("mcps");
|
|
tokio::fs::create_dir_all(&mcp_dir)
|
|
.await
|
|
.expect("mcp server dir should be created");
|
|
tokio::fs::write(mcp_dir.join("broken.toml"), "not valid toml =")
|
|
.await
|
|
.expect("broken mcp server fixture should be written");
|
|
|
|
let result = TestAppStateBuilder::new()
|
|
.active_config_path(temp_dir.path().join("settings.toml"))
|
|
.try_build();
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_mcp_server_toml_imports_at_startup_and_renames_directory() {
|
|
let temp_dir = tempfile::tempdir().expect("mcp server test tempdir should be created");
|
|
let active_config_path = temp_dir.path().join("settings.toml");
|
|
let vault_path = temp_dir.path().join("secrets.json");
|
|
let db_path = temp_dir.path().join("db").join("fabro.sqlite3");
|
|
let mcp_dir = temp_dir.path().join("mcps");
|
|
tokio::fs::create_dir_all(&mcp_dir)
|
|
.await
|
|
.expect("legacy MCP server directory should be created");
|
|
tokio::fs::write(
|
|
mcp_dir.join("sentry.toml"),
|
|
r#"
|
|
display_name = "Sentry"
|
|
startup_timeout_secs = 10
|
|
tool_timeout_secs = 60
|
|
|
|
[transport]
|
|
type = "http"
|
|
url = "https://example.com/mcp"
|
|
|
|
[transport.headers]
|
|
Authorization = "secret-value"
|
|
"#,
|
|
)
|
|
.await
|
|
.expect("legacy MCP server fixture should be written");
|
|
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path)
|
|
.vault_path(vault_path)
|
|
.build();
|
|
let app = build_test_router(state);
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers/sentry"))
|
|
.await
|
|
.expect("imported MCP server should respond");
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
"GET imported /api/v1/mcp-servers/sentry",
|
|
)
|
|
.await;
|
|
assert_eq!(body["display_name"], "Sentry");
|
|
assert_eq!(body["transport"]["header_keys"], json!(["Authorization"]));
|
|
assert!(!mcp_dir.exists());
|
|
let mut entries = tokio::fs::read_dir(temp_dir.path())
|
|
.await
|
|
.expect("test directory should be readable");
|
|
let mut backup_found = false;
|
|
while let Some(entry) = entries
|
|
.next_entry()
|
|
.await
|
|
.expect("test directory entry should be readable")
|
|
{
|
|
if entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("mcps.imported-")
|
|
{
|
|
backup_found = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(backup_found);
|
|
let persisted = persisted_mcp_server(&db_path, "sentry")
|
|
.await
|
|
.expect("imported MCP server should be in SQLite");
|
|
let McpTransport::Http { headers, .. } = persisted.transport else {
|
|
panic!("imported MCP server should use HTTP transport")
|
|
};
|
|
assert_eq!(
|
|
headers.get("Authorization").map(String::as_str),
|
|
Some("secret-value")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_servers_routes_require_authenticated_user() {
|
|
let temp_dir = tempfile::tempdir().expect("mcp server test tempdir should be created");
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(temp_dir.path().join("settings.toml"))
|
|
.build();
|
|
let app = build_router(state, test_auth_mode());
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/mcp-servers"))
|
|
.await
|
|
.expect("unauthenticated mcp server list should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNAUTHORIZED,
|
|
"GET /api/v1/mcp-servers without auth",
|
|
)
|
|
.await;
|
|
}
|