Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-app/tests/auxiliary_e2e.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

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);
}