f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
372 lines
13 KiB
Rust
372 lines
13 KiB
Rust
//! E2E integration tests for Custom Agent CRUD and try-connect endpoints.
|
|
//!
|
|
//! Covers:
|
|
//! - Create / update / delete / toggle-enable happy paths
|
|
//! - Empty-field validation (name, command)
|
|
//! - NotFound on missing id
|
|
//! - Forbidden when operating on a builtin id through the custom-only paths
|
|
//! - Test-on-save CLI-not-found rejection (runs the real probe)
|
|
//!
|
|
//! Gates the probe with NOMIFUN_BYPASS_PROBE for happy paths so CI does not
|
|
//! need an ACP CLI installed. Unset bypass for the single test that
|
|
//! verifies the CLI-not-found path.
|
|
//!
|
|
//! Thread safety: `NOMIFUN_BYPASS_PROBE` is a process-wide env var. Tests
|
|
//! that set or clear it hold `ENV_MUTEX` for their entire body so no two
|
|
//! tests race on the var simultaneously.
|
|
|
|
mod common;
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
use tokio::sync::Mutex;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
use common::{body_json, build_app, get_with_token, json_with_token, setup_and_login};
|
|
|
|
// ── Global lock for env-var mutation ─────────────────────────────────────────
|
|
//
|
|
// Any test that reads or writes NOMIFUN_BYPASS_PROBE must hold this lock for
|
|
// its entire body. This serialises all probe-sensitive tests within a single
|
|
// test binary regardless of how many threads `cargo test` uses.
|
|
|
|
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_mutex() -> &'static Mutex<()> {
|
|
ENV_MUTEX.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
/// Acquire ENV_MUTEX. `tokio::sync::Mutex` guards are async-aware and
|
|
/// may be held across await points without triggering clippy's
|
|
/// `await_holding_lock` lint.
|
|
async fn lock_env() -> tokio::sync::MutexGuard<'static, ()> {
|
|
env_mutex().lock().await
|
|
}
|
|
|
|
// ── Helper: create a custom agent and return (status, body) ──────────────────
|
|
|
|
async fn create_agent(app: &mut axum::Router, token: &str, csrf: &str, body: Value) -> (StatusCode, Value) {
|
|
let req = json_with_token("POST", "/api/agents/custom", body, token, csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let status = resp.status();
|
|
let json = body_json(resp).await;
|
|
(status, json)
|
|
}
|
|
|
|
async fn list_agents(app: &mut axum::Router, token: &str) -> Value {
|
|
let req = get_with_token("/api/agents", token);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
body_json(resp).await
|
|
}
|
|
|
|
// ── Happy path: create → list → update → toggle → delete ─────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn custom_agent_full_roundtrip() {
|
|
let _guard = lock_env().await;
|
|
// SAFETY: single env-var mutation under ENV_MUTEX; restored at function end.
|
|
unsafe {
|
|
std::env::set_var("NOMIFUN_BYPASS_PROBE", "1");
|
|
}
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// Create — use "sh" so which::which resolves it and the registry marks
|
|
// the new row as available (list_all filters out unavailable rows).
|
|
let (status, json) = create_agent(
|
|
&mut app,
|
|
&token,
|
|
&csrf,
|
|
json!({
|
|
"name": "My Claude",
|
|
"command": "sh",
|
|
"icon": "🤖",
|
|
"args": ["--acp"],
|
|
"env": []
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(json["success"], true);
|
|
let id = json["data"]["id"].as_str().expect("id in response").to_owned();
|
|
assert_eq!(json["data"]["name"], "My Claude");
|
|
assert_eq!(json["data"]["agent_source"], "custom");
|
|
assert_eq!(json["data"]["icon"], "🤖");
|
|
|
|
// List — agent should be visible
|
|
let listed = list_agents(&mut app, &token).await;
|
|
let agents = listed["data"].as_array().expect("array");
|
|
assert!(
|
|
agents.iter().any(|a| a["id"] == id),
|
|
"newly created agent should appear in GET /api/agents"
|
|
);
|
|
|
|
// Update — keep "sh" so the row stays available after rehydrate.
|
|
let req = json_with_token(
|
|
"PUT",
|
|
&format!("/api/agents/custom/{id}"),
|
|
json!({
|
|
"name": "My Claude v2",
|
|
"command": "sh",
|
|
"icon": "🚀",
|
|
"args": [],
|
|
"env": []
|
|
}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["id"], id, "id must survive update");
|
|
assert_eq!(json["data"]["name"], "My Claude v2");
|
|
assert_eq!(json["data"]["icon"], "🚀");
|
|
|
|
// Toggle disabled
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/agents/{id}/enabled"),
|
|
json!({ "enabled": false }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["enabled"], false);
|
|
|
|
// Re-enable
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
&format!("/api/agents/{id}/enabled"),
|
|
json!({ "enabled": true }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Delete
|
|
let req = json_with_token(
|
|
"DELETE",
|
|
&format!("/api/agents/custom/{id}"),
|
|
json!(null),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["deleted"], true);
|
|
|
|
// Post-delete list must not contain the id
|
|
let listed = list_agents(&mut app, &token).await;
|
|
let agents = listed["data"].as_array().unwrap();
|
|
assert!(
|
|
agents.iter().all(|a| a["id"] != id),
|
|
"deleted agent should disappear from GET /api/agents"
|
|
);
|
|
|
|
unsafe {
|
|
std::env::remove_var("NOMIFUN_BYPASS_PROBE");
|
|
}
|
|
}
|
|
|
|
// ── Advanced overrides flow ───────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn custom_agent_advanced_overrides_persist() {
|
|
let _guard = lock_env().await;
|
|
unsafe {
|
|
std::env::set_var("NOMIFUN_BYPASS_PROBE", "1");
|
|
}
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let (status, json) = create_agent(
|
|
&mut app,
|
|
&token,
|
|
&csrf,
|
|
json!({
|
|
"name": "With Advanced",
|
|
"command": "sh",
|
|
"advanced": {
|
|
"yolo_id": "bypassPermissions",
|
|
"native_skills_dirs": [".claude/skills"],
|
|
"description": "test",
|
|
"unknown_ignored_key": 42
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(json["data"]["yolo_id"], "bypassPermissions");
|
|
assert_eq!(json["data"]["native_skills_dirs"], json!([".claude/skills"]));
|
|
assert_eq!(json["data"]["description"], "test");
|
|
|
|
unsafe {
|
|
std::env::remove_var("NOMIFUN_BYPASS_PROBE");
|
|
}
|
|
}
|
|
|
|
// ── Bad path: validation ──────────────────────────────────────────────────────
|
|
//
|
|
// These tests exercise the validate_upsert() path which fires before the
|
|
// probe, so they do not need NOMIFUN_BYPASS_PROBE and do not need the lock.
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_empty_name() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let (status, json) = create_agent(&mut app, &token, &csrf, json!({ "name": "", "command": "sh" })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert_eq!(json["success"], false);
|
|
assert!(json["error"].as_str().unwrap().to_lowercase().contains("name"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_empty_command() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let (status, json) = create_agent(&mut app, &token, &csrf, json!({ "name": "x", "command": " " })).await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(json["error"].as_str().unwrap().to_lowercase().contains("command"));
|
|
}
|
|
|
|
// ── Bad path: 404 and 403 ─────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn update_unknown_id_returns_404() {
|
|
let _guard = lock_env().await;
|
|
unsafe {
|
|
std::env::set_var("NOMIFUN_BYPASS_PROBE", "1");
|
|
}
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token(
|
|
"PUT",
|
|
"/api/agents/custom/does-not-exist",
|
|
json!({ "name": "x", "command": "sh" }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
|
|
unsafe {
|
|
std::env::remove_var("NOMIFUN_BYPASS_PROBE");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_builtin_id_returns_403() {
|
|
let _guard = lock_env().await;
|
|
unsafe {
|
|
std::env::set_var("NOMIFUN_BYPASS_PROBE", "1");
|
|
}
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// agent_builtin_claude is the seeded Claude id (builtin) from the baseline schema.
|
|
let req = json_with_token(
|
|
"PUT",
|
|
"/api/agents/custom/agent_builtin_claude",
|
|
json!({ "name": "hacked", "command": "sh" }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
|
|
unsafe {
|
|
std::env::remove_var("NOMIFUN_BYPASS_PROBE");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_builtin_id_returns_403() {
|
|
// delete_custom_agent checks agent_source before calling the probe,
|
|
// so no bypass needed here.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token(
|
|
"DELETE",
|
|
"/api/agents/custom/agent_builtin_claude",
|
|
json!(null),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_enabled_unknown_id_returns_404() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token(
|
|
"PATCH",
|
|
"/api/agents/missing-id/enabled",
|
|
json!({ "enabled": false }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── Test-on-save: CLI not found ───────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_on_save_cli_not_found_blocks_upsert() {
|
|
// Hold ENV_MUTEX to guarantee NOMIFUN_BYPASS_PROBE is unset for the
|
|
// duration of this test — no bypass-setting test can interleave.
|
|
let _guard = lock_env().await;
|
|
unsafe {
|
|
// Ensure clean state regardless of test ordering.
|
|
std::env::remove_var("NOMIFUN_BYPASS_PROBE");
|
|
}
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let (status, json) = create_agent(
|
|
&mut app,
|
|
&token,
|
|
&csrf,
|
|
json!({
|
|
"name": "bad",
|
|
"command": "nomifun-definitely-nonexistent-xyz"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
let err = json["error"].as_str().expect("error string");
|
|
// AppError::BadRequest is serialized as "Bad request: <msg>", so we
|
|
// check that the marker string appears anywhere in the error field.
|
|
assert!(
|
|
err.contains("cli_not_found:"),
|
|
"error must carry cli_not_found: marker, got: {err}"
|
|
);
|
|
|
|
// DB must not have the row.
|
|
let listed = list_agents(&mut app, &token).await;
|
|
let agents = listed["data"].as_array().unwrap();
|
|
assert!(
|
|
agents.iter().all(|a| a["name"] != "bad"),
|
|
"rejected create must not leave rows behind"
|
|
);
|
|
}
|