use std::path::{Path, PathBuf};
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use bamboo_plugin::manifest::{McpServerManifestEntry, McpTransportManifest, Platform};
use crate::app_state::AppState;
use super::handlers::{install_plugin, list_plugins, remove_plugin, update_plugin};
fn hello_plugin_example_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../infra/bamboo-plugin/examples/hello-plugin")
}
async fn test_state(data_dir: &Path) -> web::Data<AppState> {
web::Data::new(
AppState::new(data_dir.to_path_buf())
.await
.expect("app state should initialize"),
)
}
macro_rules! plugin_test_app {
($state:expr) => {
App::new()
.app_data($state)
.route("/api/v1/plugins", web::get().to(list_plugins))
.route("/api/v1/plugins/install", web::post().to(install_plugin))
.route("/api/v1/plugins/{id}/update", web::post().to(update_plugin))
.route("/api/v1/plugins/{id}", web::delete().to(remove_plugin))
};
}
fn local_dir_source(path: &Path) -> serde_json::Value {
serde_json::json!({
"source": { "type": "local_dir", "path": path.to_string_lossy() }
})
}
async fn write_bad_manifest_plugin_dir(root: &Path) -> PathBuf {
let dir = root.join("bad-plugin-source");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("plugin.json"),
serde_json::json!({
"id": "Bad Id!",
"name": "Bad",
"version": "1.0.0"
})
.to_string(),
)
.await
.unwrap();
dir
}
async fn write_mcp_plugin_dir(root: &Path, plugin_id: &str, mcp_id: &str) -> PathBuf {
let dir = root.join(format!("{plugin_id}-source"));
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("plugin.json"),
serde_json::json!({
"id": plugin_id,
"name": "Test Plugin",
"version": "1.0.0",
"provides": {
"mcp_servers": [
{
"id": mcp_id,
"transport": {
"type": "stdio",
"command": "/nonexistent/bamboo-test-mcp-binary-does-not-exist"
}
}
]
}
})
.to_string(),
)
.await
.unwrap();
dir
}
async fn body_json(response: actix_web::dev::ServiceResponse) -> serde_json::Value {
let bytes = test::read_body(response).await;
serde_json::from_slice(&bytes).expect("valid json body")
}
#[actix_web::test]
async fn install_list_reinstall_conflict_then_delete() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert_eq!(view["name"], "Hello Plugin");
assert_eq!(view["version"], "0.1.0");
assert_eq!(view["status"], "installed");
assert_eq!(view["source"]["type"], "local_dir");
assert_eq!(
view["registered"]["skill_dirs"],
serde_json::json!(["hello-world"])
);
assert_eq!(
view["registered"]["preset_ids"],
serde_json::json!(["hello_plugin_greeter"])
);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let listed = body_json(resp).await;
let plugins = listed["plugins"].as_array().expect("plugins array");
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0]["id"], "hello-plugin");
assert_eq!(plugins[0]["name"], "Hello Plugin");
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let error = body_json(resp).await;
assert!(
error["error"]
.as_str()
.unwrap()
.contains("already installed"),
"error message should mention already installed: {error}"
);
let req = test::TestRequest::delete()
.uri("/api/v1/plugins/hello-plugin")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let deleted = body_json(resp).await;
assert_eq!(deleted["id"], "hello-plugin");
assert_eq!(deleted["removed"], true);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
assert!(hello_plugin_example_dir().join("plugin.json").exists());
}
#[actix_web::test]
async fn install_with_invalid_manifest_returns_400() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let plugin_source_dir = write_bad_manifest_plugin_dir(data_dir.path()).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&plugin_source_dir))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
assert!(
error["error"].as_str().unwrap().contains("invalid"),
"error message should mention the manifest is invalid: {error}"
);
}
#[actix_web::test]
async fn install_with_foreign_mcp_conflict_returns_409() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let user_entry = McpServerManifestEntry {
id: "shared-tool".to_string(),
name: None,
enabled: false,
transport: McpTransportManifest::Stdio {
command: "/usr/bin/true".to_string(),
args: vec![],
cwd: None,
env: Default::default(),
},
allowed_tools: vec![],
denied_tools: vec![],
};
let user_server = user_entry
.resolve(
Path::new("/tmp"),
"not-a-plugin",
Platform::current().unwrap_or(Platform::Linux),
)
.expect("resolve a user mcp server config");
state
.update_config(
move |cfg| {
cfg.mcp.servers.push(user_server.clone());
Ok(())
},
Default::default(),
)
.await
.expect("seed user mcp server");
let app = test::init_service(plugin_test_app!(state.clone())).await;
let plugin_source_dir =
write_mcp_plugin_dir(data_dir.path(), "conflicting-plugin", "shared-tool").await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&plugin_source_dir))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("mcp server"), "{message}");
assert!(message.contains("shared-tool"), "{message}");
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
let config = state.config.read().await;
let servers: Vec<_> = config
.mcp
.servers
.iter()
.filter(|s| s.id == "shared-tool")
.collect();
assert_eq!(servers.len(), 1);
assert!(!servers[0].enabled);
}
#[actix_web::test]
async fn update_upgrades_an_installed_plugin() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let req = test::TestRequest::post()
.uri("/api/v1/plugins/hello-plugin/update")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert_eq!(view["status"], "installed");
}
#[actix_web::test]
async fn update_with_mismatched_path_id_returns_400_and_rolls_back() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::post()
.uri("/api/v1/plugins/some-other-id/update")
.set_json(local_dir_source(&hello_plugin_example_dir()))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("some-other-id"), "{message}");
assert!(message.contains("hello-plugin"), "{message}");
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
}
#[actix_web::test]
async fn delete_unknown_id_returns_404() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let req = test::TestRequest::delete()
.uri("/api/v1/plugins/does-not-exist")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
fn hello_manifest_json(id: &str) -> String {
serde_json::json!({
"id": id,
"name": "Hello",
"version": "0.1.0",
})
.to_string()
}
fn sha256_hex_of(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
fn url_source(url: &str, sha256: Option<&str>, allow_unverified: bool) -> serde_json::Value {
url_source_full(url, sha256, allow_unverified, true, true)
}
fn url_source_full(
url: &str,
sha256: Option<&str>,
allow_unverified: bool,
allow_untrusted_host: bool,
allow_unsigned: bool,
) -> serde_json::Value {
let mut source = serde_json::json!({ "type": "url", "url": url });
if let Some(sha) = sha256 {
source["sha256"] = serde_json::Value::String(sha.to_string());
}
if allow_unverified {
source["allow_unverified"] = serde_json::Value::Bool(true);
}
if allow_untrusted_host {
source["allow_untrusted_host"] = serde_json::Value::Bool(true);
}
if allow_unsigned {
source["allow_unsigned"] = serde_json::Value::Bool(true);
}
serde_json::json!({ "source": source })
}
#[actix_web::test]
async fn install_url_with_untrusted_host_returns_403_before_fetch() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let server = wiremock::MockServer::start().await;
let url = format!("{}/plugin.json", server.uri());
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(url_source_full(&url, None, false, false, false))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("trusted_hosts"), "{message}");
assert!(
message.contains("allow_untrusted_host") || message.contains("allow-untrusted-host"),
"{message}"
);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
let received = server.received_requests().await;
assert_eq!(received.map(|r| r.len()), Some(0));
}
#[actix_web::test]
async fn install_url_with_no_checksum_or_allow_unverified_returns_400_after_host_and_signature_pass(
) {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let server = wiremock::MockServer::start().await;
let manifest_body = hello_manifest_json("hello-plugin");
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/plugin.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let url = format!("{}/plugin.json", server.uri());
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(url_source(&url, None, false))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
let message = error["error"].as_str().unwrap();
assert!(message.contains("sha256"), "{message}");
assert!(message.contains("allow_unverified"), "{message}");
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
}
#[actix_web::test]
async fn install_url_with_wrong_bundle_sha256_returns_400_and_installs_nothing() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let server = wiremock::MockServer::start().await;
let manifest_body = hello_manifest_json("hello-plugin");
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/plugin.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let url = format!("{}/plugin.json", server.uri());
let wrong_sha256 = "b".repeat(64);
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(url_source(&url, Some(&wrong_sha256), false))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let error = body_json(resp).await;
assert!(
error["error"].as_str().unwrap().contains("mismatch"),
"{error}"
);
let req = test::TestRequest::get().uri("/api/v1/plugins").to_request();
let resp = test::call_service(&app, req).await;
let listed = body_json(resp).await;
assert!(listed["plugins"].as_array().unwrap().is_empty());
}
#[actix_web::test]
async fn install_url_with_correct_bundle_sha256_succeeds() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let server = wiremock::MockServer::start().await;
let manifest_body = hello_manifest_json("hello-plugin");
let bundle_sha256 = sha256_hex_of(manifest_body.as_bytes());
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/plugin.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let url = format!("{}/plugin.json", server.uri());
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(url_source(&url, Some(&bundle_sha256), false))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert_eq!(view["source"]["type"], "url");
assert_eq!(view["source"]["sha256"], bundle_sha256);
}
#[actix_web::test]
async fn install_url_with_allow_unverified_and_no_sha256_succeeds() {
let data_dir = tempfile::tempdir().unwrap();
let state = test_state(data_dir.path()).await;
let app = test::init_service(plugin_test_app!(state.clone())).await;
let server = wiremock::MockServer::start().await;
let manifest_body = hello_manifest_json("hello-plugin");
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/plugin.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string(manifest_body))
.mount(&server)
.await;
let url = format!("{}/plugin.json", server.uri());
let req = test::TestRequest::post()
.uri("/api/v1/plugins/install")
.set_json(url_source(&url, None, true))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let view = body_json(resp).await;
assert_eq!(view["id"], "hello-plugin");
assert!(view["source"]["sha256"].is_null());
}