f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
435 lines
17 KiB
Rust
435 lines
17 KiB
Rust
//! E2E integration tests for auxiliary conversation routes.
|
|
//!
|
|
//! Tests cover: workspace browse, side-question,
|
|
//! slash-commands, and openclaw-runtime endpoints.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use common::{body_json, get_with_token, json_with_token, setup_and_login};
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
|
|
fn create_conv_body(name: &str, agent_type: &str) -> serde_json::Value {
|
|
json!({
|
|
"type": agent_type,
|
|
"name": name,
|
|
"extra": { "workspace": "/project" }
|
|
})
|
|
}
|
|
|
|
fn create_conv_body_with_workspace(name: &str, agent_type: &str, workspace: &str) -> serde_json::Value {
|
|
json!({
|
|
"type": agent_type,
|
|
"name": name,
|
|
"extra": { "workspace": workspace }
|
|
})
|
|
}
|
|
|
|
async fn create_conversation_with_workspace(
|
|
app: &mut axum::Router,
|
|
token: &str,
|
|
csrf: &str,
|
|
name: &str,
|
|
agent_type: &str,
|
|
workspace: &str,
|
|
) -> String {
|
|
let req = common::json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_conv_body_with_workspace(name, agent_type, workspace),
|
|
token,
|
|
csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = common::body_json(resp).await;
|
|
json["data"]["id"].as_i64().unwrap().to_string()
|
|
}
|
|
|
|
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str, name: &str, agent_type: &str) -> String {
|
|
let req = common::json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
create_conv_body(name, agent_type),
|
|
token,
|
|
csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let json = common::body_json(resp).await;
|
|
json["data"]["id"].as_i64().unwrap().to_string()
|
|
}
|
|
|
|
async fn build_app() -> (axum::Router, nomifun_app::AppServices) {
|
|
let db = nomifun_db::init_database_memory().await.unwrap();
|
|
let services = nomifun_app::AppServices::from_config(db, &nomifun_app::AppConfig::default())
|
|
.await
|
|
.unwrap();
|
|
let router = nomifun_app::create_router(&services).await;
|
|
(router, services)
|
|
}
|
|
|
|
// ── 9.1 Workspace browse ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn workspace_browse_requires_auth() {
|
|
let (app, _) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/conversations/test-conv/workspace?path=/src")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_browse_no_active_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
// Seed a real workspace on disk so the handler can canonicalize it.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
|
std::fs::write(tmp.path().join("src/lib.rs"), b"// hi").unwrap();
|
|
|
|
let ws = tmp.path().to_string_lossy().into_owned();
|
|
let conv_id = create_conversation_with_workspace(&mut app, &token, &csrf, "Test Conv", "acp", &ws).await;
|
|
|
|
let req = get_with_token(&format!("/api/conversations/{conv_id}/workspace?path=/src"), &token);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
// Workspace comes from DB; no active agent required.
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
let entries = json["data"].as_array().unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0]["name"], "lib.rs");
|
|
assert_eq!(entries[0]["type"], "file");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_browse_conversation_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
let req = get_with_token("/api/conversations/does-not-exist/workspace?path=/src", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_browse_empty_path() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
let req = get_with_token("/api/conversations/some-conv/workspace?path=", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Empty path should return 400 (validated before agent lookup)
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn workspace_browse_treats_symlinked_skill_dir_as_directory() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let workspace = tmp.path().join("workspace");
|
|
let builtin = tmp.path().join("builtin-skills/auto-inject/nomifun-skills");
|
|
std::fs::create_dir_all(workspace.join(".claude/skills")).unwrap();
|
|
std::fs::create_dir_all(&builtin).unwrap();
|
|
std::fs::write(builtin.join("SKILL.md"), b"---\ndescription: test\n---\nbody").unwrap();
|
|
std::os::unix::fs::symlink(&builtin, workspace.join(".claude/skills/nomifun-skills")).unwrap();
|
|
|
|
let ws = workspace.to_string_lossy().into_owned();
|
|
let conv_id = create_conversation_with_workspace(&mut app, &token, &csrf, "Test Conv", "acp", &ws).await;
|
|
|
|
let req = get_with_token(
|
|
&format!("/api/conversations/{conv_id}/workspace?path=/.claude/skills"),
|
|
&token,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
let entries = json["data"].as_array().unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry["name"] == "nomifun-skills" && entry["type"] == "directory"),
|
|
"symlinked skill dir should stay visible as directory: {entries:?}"
|
|
);
|
|
|
|
let req = get_with_token(
|
|
&format!("/api/conversations/{conv_id}/workspace?path=/.claude/skills/nomifun-skills"),
|
|
&token,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
let entries = json["data"].as_array().unwrap();
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.any(|entry| entry["name"] == "SKILL.md" && entry["type"] == "file"),
|
|
"symlinked skill dir should remain browsable: {entries:?}"
|
|
);
|
|
}
|
|
|
|
// ── 9.1b Terminal workspace browse ──────────────────────────────
|
|
//
|
|
// The terminal analogue of `GET /api/conversations/{id}/workspace`:
|
|
// `GET /api/terminals/{id}/workspace` lists one directory level under the
|
|
// session's server-authoritative `cwd`. These e2e tests guard the routing +
|
|
// auth wiring (the service layer has its own unit tests); they mirror
|
|
// `workspace_browse_requires_auth` / `workspace_browse_no_active_task` above.
|
|
|
|
/// Create a terminal session row WITHOUT a live PTY. `defer_spawn: true` makes
|
|
/// the service persist the row and defer the PTY to the first resize, so no
|
|
/// process is spawned in the test harness. Returns the DB-minted id as a string.
|
|
async fn create_terminal_with_cwd(app: &mut axum::Router, token: &str, csrf: &str, cwd: &str) -> String {
|
|
let req = common::json_with_token(
|
|
"POST",
|
|
"/api/terminals",
|
|
json!({
|
|
"name": "Test Terminal",
|
|
"cwd": cwd,
|
|
"command": "cat",
|
|
"defer_spawn": true
|
|
}),
|
|
token,
|
|
csrf,
|
|
);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED, "terminal create should succeed");
|
|
let json = common::body_json(resp).await;
|
|
json["data"]["id"].as_i64().unwrap().to_string()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_workspace_requires_auth() {
|
|
let (app, _) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/terminals/1/workspace?path=")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
// Mounted behind the auth middleware → unauthenticated request rejected
|
|
// before the handler runs (same status as the conversation analogue).
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_workspace_lists_cwd_entries() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
// Seed a real workspace on disk; the handler derives the root from the
|
|
// session's `cwd` (server-authoritative) and lists one level.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
std::fs::write(tmp.path().join("hello.txt"), b"hi").unwrap();
|
|
let cwd = tmp.path().to_string_lossy().into_owned();
|
|
|
|
let term_id = create_terminal_with_cwd(&mut app, &token, &csrf, &cwd).await;
|
|
|
|
let req = get_with_token(&format!("/api/terminals/{term_id}/workspace?path="), &token);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
// Root comes from the DB row's cwd; no live PTY required.
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
let entries = json["data"].as_array().unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0]["name"], "hello.txt");
|
|
assert_eq!(entries[0]["type"], "file");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_workspace_not_found() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
// Authenticated, but no such terminal session row → 404 (the service
|
|
// surfaces a missing row as NotFound before any filesystem access).
|
|
let req = get_with_token("/api/terminals/999999/workspace?path=", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── 9.2 Side question ───────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn side_question_requires_auth() {
|
|
let (app, _) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/conversations/test-conv/side-question")
|
|
.header("content-type", "application/json")
|
|
.body(axum::body::Body::from(r#"{"question":"test?"}"#))
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_question_empty_question() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
|
|
// side-question is now dispatched to AgentInstance after the
|
|
// conversation lookup, so a missing conversation surfaces as 404
|
|
// before the empty-question check gets a chance to fire.
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/conversations/some-conv/side-question",
|
|
json!({ "question": "" }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_question_no_active_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &csrf, "Side Q Test", "acp").await;
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/conversations/{conv_id}/side-question"),
|
|
json!({ "question": "What is this?" }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// No active agent → 404
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── 9.4 Slash commands ──────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn slash_commands_requires_auth() {
|
|
let (app, _) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/conversations/test-conv/slash-commands")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn slash_commands_no_active_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &_csrf, "Slash Test", "acp").await;
|
|
|
|
let req = get_with_token(&format!("/api/conversations/{conv_id}/slash-commands"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── 9.5 OpenClaw runtime ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn openclaw_runtime_requires_auth() {
|
|
let (app, _) = build_app().await;
|
|
let req = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/conversations/test-conv/openclaw/runtime")
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn openclaw_runtime_no_active_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &_csrf, "OpenClaw Test", "openclaw-gateway").await;
|
|
|
|
let req = get_with_token(&format!("/api/conversations/{conv_id}/openclaw/runtime"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── Confirmation routes (no active task → graceful defaults) ─────
|
|
|
|
#[tokio::test]
|
|
async fn list_confirmations_no_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &csrf, "Confirm Test", "acp").await;
|
|
|
|
let req = get_with_token(&format!("/api/conversations/{conv_id}/confirmations"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// No active agent → returns empty list gracefully
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert!(json["data"].as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn confirm_call_no_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &csrf, "Confirm Test", "acp").await;
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/conversations/{conv_id}/confirmations/call-1/confirm"),
|
|
json!({ "msg_id": "msg-1", "data": { "value": "allow" }, "always_allow": false }),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn check_approval_no_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &csrf, "Approval Test", "acp").await;
|
|
|
|
let req = get_with_token(
|
|
&format!("/api/conversations/{conv_id}/approvals/check?action=edit_file"),
|
|
&token,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// No active agent → returns approved=false gracefully
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["approved"], false);
|
|
}
|
|
|
|
// ── Stop + Warmup (no active task → idempotent success) ───────
|
|
|
|
#[tokio::test]
|
|
async fn stop_stream_no_task() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "user1", "pass123").await;
|
|
let conv_id = create_conversation(&mut app, &token, &csrf, "Stop Test", "acp").await;
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/conversations/{conv_id}/cancel"),
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Stop with no active agent is idempotent.
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|